Crux Read real JWT verification snippets and a forged token, spot the vulnerability, and pick the highest-leverage fix a security-minded senior makes first.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at senior altitude — in orbit
◷ 14 min
JWT bugs hide in the verify call and the claim checks that follow it. Read each snippet the way you would in a security review, find what the attacker exploits, and choose the fix before adding another line of code.
Goal
Practise the review loop you run on every auth path: read the verify call, ask what the attacker controls, and reach for the pin-and-validate fix that closes the trust gap.
What does this code grant the attacker, and what is the fix?
Heads-up decode parses base64url and returns claims regardless of the signature; it never validates one. The empty-signature token decodes fine and the role check passes.
Heads-up The danger here is not even alg:none handling — the code calls decode, which skips verification entirely. Even a properly-signed verifier is bypassed because verify is never called.
Heads-up The snippet grants admin purely on the unverified role claim. There is no second factor in the code path; the forged claim is acted on directly.
An attacker forges a token with alg:HS256 signed using the contents of rsa-public.pem as the HMAC secret. Why does verifyToken accept it?
Heads-up Without an explicit allowlist, the library picks the algorithm from the header, not from the key's file extension. The header says HS256, so the HMAC path runs with the public key as the secret.
Heads-up Key length is irrelevant to confusion; the public key is used verbatim as the HMAC secret. The bug is letting the header choose HS256 at all.
Heads-up The file is read correctly. The attack reproduces the server's exact HMAC computation by using the same public-key string as the secret — no corruption involved.
Snippet 3 — signature checked, claims not
const claims = jwt.verify(token, key, { algorithms: ["RS256"] });// signature OK — now trust everythingreq.user = claims.sub;req.roles = claims.roles;// no exp / iss / aud checks beyond the library default
Quiz
Completed
The algorithm is pinned and the signature verifies. In a microservice mesh sharing one signing key, what is still wrong?
Heads-up A valid signature only proves authentic issuance. Without aud, any service sharing the key accepts tokens minted for any other service in the mesh.
Heads-up Field-read order is irrelevant. The defect is accepting a token whose audience is a different service entirely, because aud is never validated.
Heads-up Algorithm choice is unrelated to the missing audience check. Switching to HS256 would actually widen confusion risk, not fix the audience scoping.
How does an attacker fully control verification here, and what is the fix?
Heads-up HTTPS authenticates the attacker's own server, not that the key belongs to you. The header chose the URL, so transport security does not stop a key the attacker controls.
Heads-up Caching an attacker-supplied key just pins the wrong key. The vulnerability is sourcing the key from a header-named URL at all, not the fetch frequency.
Heads-up The attacker's own JWKS contains a matching kid by construction. Matching against an attacker-hosted set proves nothing; the key set must come from a trusted server-side source.
Recap
Every JWT defect is read in the verify call and the checks around it: decode-only code trusts unsigned claims; a missing algorithms allowlist lets the header pick HS256 and turns a public key into an HMAC secret; a verified signature with no aud/iss check is a confused-deputy waiting to happen in a shared-key mesh; and resolving kid/jku from attacker-controlled headers hands key selection to the attacker. The fix is always the same shape — pin the algorithm, bind and allowlist the key, then validate the registered claims against what this service expects.