How to debug a JWT that won't validate
You're holding a JWT in your hand. You paste it into a decoder — the payload looks right, the expiration is in the future, the claims match what you expect. But when the client sends it to the server, verification fails. This is one of those situations where the error message is almost always unhelpful: libraries log "invalid token" or "signature verification failed" and stop there.
The good news: there are about nine things that go wrong, and they're nearly always one of them. This is a working checklist. Go through it in order — the causes are ranked roughly by how often they're the culprit.
1. The wrong key, full stop
The single most common cause. HS256 tokens are signed with a shared secret; RS256 and ES256 tokens are signed with a private key and verified with the corresponding public key. If the verifier doesn't have the exact right key, the signature check will fail — even if everything else is correct.
Concrete checks:
- HMAC (HS256/384/512): the secret on both sides is literally byte-identical. Watch for trailing newlines, copy-paste spaces, or base64-encoded secrets that got double-decoded.
- RSA / EC: the verifier has the public key matching the private key the issuer used. If the issuer has rotated keys, the old public key might still be in your cache.
- JWKS endpoints: check the
kid(key ID) header on the token and verify it matches one of the keys currently served at the issuer's/.well-known/jwks.json(or equivalent).
A fast sanity check: sign a known payload with your key on both sides, compare the output. If they differ, the key isn't the same.
2. Clock skew
JWTs carry iat (issued at), nbf (not before), and exp (expiration). Most libraries compare these against the verifier's local clock with zero tolerance. If the verifier's clock is even a few seconds ahead of the issuer's, a freshly-issued token can fail with "token used before issued" or "not yet valid." If the verifier's clock is behind, an expired token might silently validate.
Fix: configure a clock skew tolerance on your verifier — 30 to 60 seconds is standard. Most libraries expose this as leeway, clockTolerance, or clock_skew. Also run NTP on every machine involved.
3. Algorithm mismatch
The token's header has an alg field. Your verifier should pin the expected algorithm, not trust whatever the token declares. If the issuer signs with RS256 and your verifier is configured for HS256, verification will fail — possibly with a misleading error about invalid key format.
Related: if you only ever accept HS256, make sure your library isn't willing to fall through to RS256 with a public key that got accidentally passed as the secret — this is the root of the classic "HS256/RS256 confusion" vulnerability. Pin the algorithm explicitly.
4. The alg: none bug
alg: none, an attacker can forge any token they like — no key required. This is a library bug that has recurred multiple times since 2015. Audit for it.
The JWT spec defines a none algorithm for unsigned tokens. A broken verifier that naively trusts the alg header will happily accept a token with the signature part left empty. The defense is what was mentioned above: always pin the expected algorithm at the verifier, and make sure your library rejects none by default (modern libraries do).
5. Audience or issuer mismatch
If you issue JWTs for multiple services, you should be checking:
iss(issuer) matches the trusted issuer string exactly — includinghttps://prefix, trailing slash, and exact subdomain.aud(audience) is either your service's identifier or (if an array) contains it.
These are often the difference between a token that validates in staging and one that fails in production. Staging and prod typically have different issuer URLs, and a token minted for staging will be structurally valid but will fail the iss check on prod.
6. Claims you forgot to check
A valid signature only proves the token came from someone who held the signing key. It doesn't prove the token was meant for your endpoint, or for the action being attempted. Common claims that should be checked but often aren't:
scopeorscp— does this token carry the permission needed for this operation?azp(authorized party) — for OAuth tokens, which client was this issued to?typ— is this an access token or an ID token? They're often confused.acr/amr— if you require specific authentication methods (e.g. MFA), check that they were used.
The JWT library's built-in validation usually covers signature, exp, nbf, and optionally iss/aud. Everything else is application logic you have to write.
7. Base64URL vs. Base64 confusion
JWT parts are Base64URL-encoded, not standard Base64. If something in your pipeline — a proxy, a logging layer, a custom middleware — has re-encoded the token through a standard Base64 decoder and re-encoder, the +// characters will have been swapped for -/_ (or vice versa), and the signature won't match.
A quick test: count the +, /, -, and _ characters in the incoming token. If you see any + or /, the token has been re-encoded at some point. Find where and fix it.
8. Character encoding drift
HMAC signatures are computed over the exact byte sequence of header.payload. If anything in transit or storage converts between UTF-8 and another encoding (UTF-16, Windows-1252, Latin-1), bytes can shift and the HMAC will fail to verify. This is rare in pure HTTP flows but common when tokens transit through message queues, database columns with the wrong collation, or legacy SOAP gateways.
Ensure every layer from token generation to verification treats the token as an opaque ASCII byte sequence. Don't "normalize" it.
9. Key rotation lag
If your issuer rotates signing keys (which it should), and your verifier caches the public key, there's a window where the issuer is signing with a new key but the verifier still has the old one. Fix with a short cache TTL on the JWKS endpoint (5–15 minutes), and make sure the verifier will re-fetch on an unrecognized kid.
A simple debugging order
When a token fails, work through this list top to bottom:
- Decode the token (e.g. on DevDecoder's JWT page). Is the header sane? Is the payload what you expect?
- Is the
expin the future? Is thenbfin the past? Is the clock on the verifier correct? - Does the
issmatch the trusted issuer string exactly? Doesaudcontain your service? - Is the
algwhat your verifier expects? Isalg: nonebeing rejected? - Is the
kid(if present) a key your verifier currently knows about? - Sign a test token with your verifier's configured key and compare the signature bytes against the failing token's signature bytes (for HMAC).
- Check for Base64URL vs. standard Base64 corruption anywhere in the pipeline.
Eight out of ten JWT-won't-validate incidents resolve at step two or three.
What a decoder can and can't tell you
A pure-browser JWT decoder (like the one on DevDecoder) can show you the header, the payload, and whether the token is expired based on your local clock. It can't verify the signature — that requires the secret key (HMAC) or the public key (RSA/ECDSA), which would have to be pasted in. The safer pattern is to inspect the structure client-side, then run signature verification in a trusted environment: your backend, a CLI tool like jwt-cli, or a local script.
When debugging, the inspector is usually enough: most JWT failures are not signature failures in the cryptographic sense — they're claim mismatches that a good look at the payload will reveal.