A JSON Web Token is three pieces of base64 text, and the middle one, the payload, is readable by anyone. JWT verification is the work that turns it from a claim into a fact, and it has more parts than "the signature matched". Your code has to decide which algorithm it accepts before it looks at the token, confirm who issued it, confirm it was meant for this service, and refuse it once it has expired. When any one of those is left to the token itself, an attacker writes the answer. This guide walks through each step, names the mistake that usually sits there, and finishes with a test that is able to fail.
Decide the algorithm yourself
Every signed token announces its own algorithm in the alg header, and the alg header is the first thing a forger edits. That field was meant to allow flexibility, and it created the best-known class of JWT bugs. RFC 8725 describes two of them. In the first, the attacker changes the header to none, and a library that trusts the value reports the token as valid without checking any signature. In the second, a service that expects an RSA signature receives a header that names an HMAC algorithm; a careless library then uses the public RSA key, which everybody can download, as the HMAC secret, and the attacker signs with it.
The remedy in the same document is that the caller names the accepted algorithms and the library refuses everything else, that the header's value must equal the algorithm actually used, and that each key serves exactly one algorithm. In code this means passing an explicit list to the verify call, for example only the one asymmetric algorithm your issuer uses. Reading alg from the header and handing it back to the library is the mistake, even though it looks harmless and even works in tests.
One consequence: a library that offers a "decode" function and a "verify" function is offering you a trap. Decode reads the payload without checking anything. It is fine for peeking at the key id or for logging, and a disaster as the thing that feeds an authorisation decision.
Treat none as a configuration, not a feature
The none algorithm exists for tokens protected some other way. RFC 8725 says libraries should not accept it unless the caller explicitly asks. Check two places: the list of allowed algorithms must not contain it, and no environment flag, test shortcut or "development mode" branch may switch signature checking off. The latter is how a bypass written for local testing reaches production, because the branch is still there and only the flag decides.
Pin the key to the issuer
Verifying a signature proves that someone with a key signed the token. It does not prove that the key belongs to the party you trust. When a token carries an iss claim, RFC 8725 section 3.8 wants the keys behind the signature tied to that issuer, with rejection when they are not. In practice you fetch keys from one known place, such as the issuer's published key set at a fixed address, and you compare the iss value to a string you configured. Two mistakes recur here. Accepting any issuer because the signature checks out against a key you fetched, and fetching keys from a location named in the token itself.
Check the audience
If one issuer serves several applications, a token minted for the billing service is a valid signature for the reporting service too. The aud claim is what tells them apart, and RFC 8725 tells the receiver to turn away any token that has no audience or names a different party. The typical failure is a verify call with no audience option, which many libraries treat as "do not check". This is the cross-service confusion the same RFC lists as a distinct attack. It matters most with identity providers shared across many products, where tokens for a low-risk app can be replayed against a sensitive one.
Related is token type. If your system issues several kinds of tokens, an access token and an ID token for instance, the RFC suggests an explicit typ header and a check for it, so one kind cannot be passed off as another.
Expiry is a check you must not disable
RFC 7519 forbids accepting a token once its exp moment has arrived, and permits a little tolerance for clock drift, typically a couple of minutes at most. Libraries usually enforce this by default, which makes the failure mode a deliberate option such as "ignore expiration", turned on while debugging and never turned off. Search the code base for such options. Also look for the reverse: tokens issued without an exp at all, since the claim is optional in the specification and a missing one means the token never dies. Your issuer should always set it, and your verifier should require its presence rather than accept silence.
The same document states a general rule that is easy to forget: if any validation step fails, the whole token must be rejected. Code that logs a warning on a failed audience check and carries on is not validating.
Handle kid as untrusted input
The kid header names which key to use, so the verifier looks it up. That lookup is where input from an unauthenticated stranger reaches your database or file system. RFC 8725 warns that the value can carry an injection payload for SQL or directory queries, and that headers such as jku and x5u, which contain URLs, can drive server-side request forgery if followed blindly. The safe pattern is a lookup in an in-memory map of keys you loaded yourself: if the id is not in the map, reject. Never build a query by concatenating the header, and never fetch a URL the token supplies unless it matches a short list you control.
Secrets do not belong in source
A signing secret written into the code ends up in version control, build logs and every copy of the repository. Keep it in the platform's secret store, make it long and random, and rotate it. For services that only verify, prefer asymmetric keys so the verifier never holds anything able to mint tokens. If you must use a shared secret, remember that anyone who reads it can forge any token for any user.
A test that can fail
A verification routine that has only ever accepted good tokens has not been tested. Build a small set of bad ones and require your code to reject every one:
- A token with a valid signature but an
expin the past. - A token signed correctly by a key you control but carrying a different
iss. - A token with the right issuer but an
audnaming another service. - The same payload with the header changed to
noneand the signature removed. - A token whose header names an algorithm outside your list, signed with the public key as an HMAC secret.
- A token whose
kidis not in your key map.
Run all six against the function your routes call, not against a helper. Then break the function on purpose, for instance by removing the audience option, and confirm that test three now fails. If it keeps passing, the test is reaching a different code path than production does.
For the habits around reviewing code with a model, our note on model-assisted code review defaults shows why a "no findings" answer needs a case that is known to fail.
When this does not apply
These steps assume your service receives tokens and decides on them. If a gateway or an identity-aware proxy in front of your service performs verification and your service never sees a raw token, the checks belong there, and your job is to make sure nothing can reach the service bypassing the gateway. Sessions held on the server with an opaque identifier have none of these issues, and for many applications they are the simpler choice. The two RFCs also leave room: they set no key length or token lifetime, so those numbers are your policy, and decisions such as whether to require typ depend on how many token kinds you run. We did not test any JWT library for this guide; it is a reading of the public documents linked above, dated 27 October 2026, and library behaviour changes between versions, so test the one you use.
Checking it automatically
When you want another reviewer for the code, our jwt-verification-review skill reads one piece of token-handling code and reports the verification gaps that let a forged or outdated token pass: an algorithm taken from the header, a disabled expiry check, no audience or issuer comparison, a payload read without verifying it, and a secret placed in the source. It stays silent on safe patterns, for example when the accepted algorithms are fixed where verification happens. The store, with the rest of our skills, is at https://aiskills402.com.
Read this post as Markdown: /blog/jwt-verification-mistakes.md · Atom feed.
