How to Debug a Broken JWT Without Backend Access
A step-by-step way to figure out why a JSON Web Token is failing — decoding it, checking the claims, and re-signing it to isolate the problem — using nothing but your browser.
"401 Unauthorized" is the least informative error message in web development, and JWTs make it worse: the token looks fine, it was definitely issued by the right service, and yet the API rejects it. You don't have the backend's signing key, you can't add a console.log to someone else's auth middleware, and the error response usually just says invalid token with no further detail.
Most JWT failures fall into a small set of categories, and you can isolate which one you're looking at with nothing but the token string itself and a browser.
Step 0: know what you're actually looking at
A JWT is three base64url-encoded segments separated by dots: header.payload.signature. The header and payload are just base64url-encoded JSON — readable by anyone, not encrypted — and the signature is a cryptographic proof that the header and payload haven't been tampered with since whoever holds the secret (or private key) signed them.
That last point is the one people most often get backwards: a JWT is signed, not encrypted. Anyone with the token can read the payload. Never put anything in a JWT payload you wouldn't be comfortable putting in a URL query parameter — if you find a password, a full credit card number, or similar sitting in a decoded payload, that's a finding worth raising on its own, separate from whatever bug brought you here.
Step 1: decode it and read the claims
Paste the token into the JWT Decoder. It splits the three segments, decodes the header and payload JSON, and — if you have the secret or public key — verifies the signature directly, so you don't need to do steps 2-4 by hand if you already have the key.
Three fields in the payload cause the large majority of "valid-looking token, rejected anyway" failures:
exp(expiry) — a Unix timestamp in seconds. If this is in the past, the token is expired, full stop, and no amount of signature-checking matters. This is by far the most common cause and the first thing to rule out. Watch for the seconds-vs-milliseconds mixup:expis seconds since epoch, and pasting a JavaScriptDate.now()value (milliseconds) into anexpfield produces a timestamp roughly 50,000 years in the future — which looks "valid" but means the token issuer's code has a bug.nbf(not before) — the inverse ofexp. A token with annbfin the future will be rejected by a spec-compliant verifier even though it hasn't expired, which is a confusing failure if you don't know to check for it.iss/aud(issuer/audience) — many APIs check that the token was issued by the expected issuer and intended for the expected audience, rejecting tokens that are otherwise perfectly valid but were meant for a different service. If your payload'sauddoesn't match the API you're calling, that's your answer.
Also check the header's alg field. If it says none, you're looking at a token from a deliberately insecure test setup or a security problem, not a bug — a well-configured server should never accept an unsigned token.
Step 2: reproduce the signature by hand
If the claims all look correct and the token is still rejected, the problem is almost always the signature — which usually means the secret used to sign it doesn't match the secret the API is verifying against.
You can confirm this without the real secret, using one you control, by building a parallel test case:
- Use the JWT Generator to build a token with the same header algorithm (HS256 is the common case) and a payload you construct yourself, signed with a secret you choose — something memorable like
test-secret-123. - Decode that token back in the JWT Decoder using the same secret, and confirm it verifies.
- Now you have a known-good pair (token + secret) to compare the shape of your broken token against — same three-segment structure, same header fields, same general payload format.
This isolates whether the problem is structural (a malformed header, a payload that isn't valid JSON, a wrong alg) versus a secret mismatch, which is the single most common real-world cause: a token signed with a staging secret being verified against a production secret, or a secret with trailing whitespace or a wrong encoding copied out of an environment variable.
Step 3: check the signature math directly with HMAC
For the HS256/384/512 family (JWT's most common signing algorithm, where the "signature" is actually an HMAC), you can verify the cryptographic piece completely independently of any JWT-specific tooling, which is useful when you suspect the bug is in how your own code constructs the signing input rather than in the JWT library itself.
The actual input that gets signed is the ASCII string base64url(header) + "." + base64url(payload) — not the decoded JSON, the still-encoded header and payload joined by a literal dot. Take that exact string and the secret, and run it through the HMAC Generator with the matching hash (SHA-256 for HS256, SHA-384 for HS384, SHA-512 for HS512). If the HMAC output matches the token's third segment (after converting from base64url to hex, since the generator outputs hex and the token segment is base64url), the signature is cryptographically correct and the rejection is happening somewhere else — a claims check, a clock skew issue, or the API checking against a different secret than you're testing with. If it doesn't match, you've confirmed the signature itself is the problem, which usually traces back to either the wrong secret or — in code that builds JWTs by hand instead of using a library — whitespace or newline differences in how the signing input string got constructed.
Step 4: rule out encoding mismatches
JWT uses base64url encoding specifically — the URL-safe variant that replaces + with - and / with _, and drops the = padding that standard base64 uses. This distinction causes two specific bugs:
- Code that encodes with standard base64 instead of base64url produces segments containing
+,/, or=characters, which breaks when the token is passed as a URL query parameter (where+and/have special meaning) even though the token itself is otherwise correctly formed. - Code that decodes a JWT segment with a standard base64 decoder instead of a base64url-aware one fails on tokens containing
-or_, which standard base64 doesn't expect.
The Base64 Encoder handles standard base64, so if you're manually reconstructing or inspecting a segment, remember to swap -→+ and _→/ (and re-add = padding to a multiple of 4 characters) before decoding it there — or just paste the whole token into the JWT Decoder from Step 1, which already handles the base64url variant correctly.
If the token is arriving via a URL (a password reset link, an email verification link, an OAuth redirect) rather than an Authorization header, also check whether it's been percent-encoded on top of its base64url encoding. The URL Encoder / Decoder will show you immediately whether a token string contains %-escaped characters that need to be decoded once before the JWT segments underneath are usable.
Step 5: when it's not actually HMAC
If the header's alg says RS256, ES256, or similar instead of an HS* value, you're looking at asymmetric signing (RSA or ECDSA) rather than HMAC, and the HMAC-matching approach in Step 3 won't apply — the "secret" in that case is a private key on the issuer's side and a public key on the verifier's side, and a mismatch there usually means the verifier is pointed at the wrong public key (a common cause: a key rotation where the old public key is still cached somewhere). The Hash Generator is still useful here for a narrower check: if the API or your own logs expose a hash/fingerprint of the expected signing key, you can hash the key you're using locally and compare, which at least confirms whether you're holding the key you think you're holding.
A quick triage order
When a JWT-based request fails and you don't know why yet, check in this order — it's roughly sorted from "most common and fastest to check" to "least common and slowest":
exp/nbfin the decoded payload — clock problems account for a large share of real-world JWT bugs.iss/aud— right token, wrong destination.alg— confirm you're testing the signature the way the token actually claims to be signed.- The signing input string — reconstruct
base64url(header) + "." + base64url(payload)and HMAC it yourself if it's an HS* algorithm. - Encoding — base64url vs. standard base64, and whether a URL-transport layer added percent-encoding on top.
Every tool above runs entirely in your browser — nothing about the token or the secret you're testing with gets sent anywhere, which matters given that both are, by definition, credentials. Bookmark the Developer Tools hub for the rest of the encoding and hashing utilities that come up around this kind of debugging.
Tools used in this guide
JWT Decoder
Paste a JWT, see the header, payload, and claims — and verify the signature.
JWT Generator
Build and sign a JSON Web Token (HS256/384/512) from a header, payload, and secret.
HMAC Generator
Compute an HMAC-SHA1/256/384/512 of a message with a secret key, right in your browser.
Base64 Encoder
Encode text to Base64 or decode it back, with correct UTF-8 handling.
URL Encoder / Decoder
Percent-encode or decode text and URLs, component or full-URL scope.