awesome-everything RU
↑ Back to the climb

Security

JWT security pitfalls: alg confusion, no revocation, and where tokens leak

Crux A JWT is only as safe as how you verify it. The classic failures — accepting alg:none, RS256→HS256 confusion, no revocation, and tokens in localStorage — all come from trusting the token instead of pinning what the server expects.
Your altitude — climbing toward senior
ZeroJuniorMiddleSenior
You are at junior altitude — the surface
◷ 16 min

A pentester logs in as a normal user, copies the JWT, flips the header from "alg":"RS256" to "alg":"HS256", changes the payload to "role":"admin", and re-signs it using the server’s public RSA key as the HMAC secret. The server reads the header, takes the HMAC branch, verifies with that same public key — and waves it through. No private key was ever needed. The “secret” was published in the JWKS endpoint the whole time.

The token tells you nothing until you verify it

A JWT has three base64url parts: header, payload, signature. The header carries alg (which algorithm), the payload carries claims (sub, role, exp), and the signature is what makes the first two unforgeable — if you verify it correctly. The dangerous mental model is treating the decoded payload as facts. It is not. Anyone can base64-decode and rewrite the payload in a browser console. The only thing standing between an attacker and "role":"admin" is signature verification, and every pitfall below is a way that verification quietly does nothing.

RFC 8725 (JSON Web Token Best Current Practices, 2020) exists because the spec’s flexibility — letting the token name its own algorithm — is the root cause of most JWT attacks. Its blunt rule: the application must decide which algorithms and keys are acceptable, and verify the token against that, not against what the header asks for.

alg:none — the empty signature that some libraries accepted

The JWT spec defines an "alg":"none" value meaning “this token is unsigned.” It exists for tokens already protected by some other layer. The catastrophe: early library versions, handed a token with "alg":"none" and an empty signature segment, returned “valid.” An attacker just sets the header to none, deletes the signature, writes any payload they want, and is authenticated as anyone.

The fix is one line of discipline: the verifier must be told the expected algorithm and reject everything else. jwt.verify(token, key, { algorithms: ["RS256"] }) — an allowlist that does not contain none makes the attack impossible. Never call a decode-only function (jwt.decode, which skips the signature) and act on its claims.

RS256 → HS256 algorithm confusion

This is the subtler, nastier cousin and the one in the hook. Asymmetric RS256 uses a key pair: a private key signs, a public key verifies, and the public key is meant to be shared — it sits in your /.well-known/jwks.json. Symmetric HS256 uses one secret for both signing and verifying.

The attack exploits a server that picks its verification path from the token’s alg header:

  1. Attacker grabs the public key (it’s public).
  2. Attacker forges a token with "alg":"HS256" and signs it with HMAC-SHA256 using the public key string as the HMAC secret.
  3. The server reads HS256, branches into the HMAC code path, and runs HMAC-SHA256 with its public key as the secret — the exact operation the attacker just did.
  4. The signatures match. The forged admin token is accepted.

The whole thing works because the server trusted the header’s alg and used one key with two algorithms. RFC 8725 names both defenses directly: pin the expected algorithm (an explicit allowlist passed to verify), and bind each key to exactly one algorithm so an RS256 public key can never be fed into an HMAC verifier.

PitfallWhat the attacker doesThe fix
alg: none acceptedSets alg to none, empties the signature, rewrites payloadPass an algorithm allowlist to verify; never accept none
RS256 → HS256 confusionSigns HS256 with the public RSA key as the HMAC secretPin the algorithm; bind each key to one algorithm only
No revocationReplays a stolen token until it expiresShort access TTL (5–15 min) + refresh-token rotation
Token in localStorageXSS reads the token via JavaScript and exfiltrates ithttpOnly cookie or in-memory + short TTL
Weak HMAC secretBrute-forces the secret offline from a captured token≥32 random bytes (256-bit) from a CSPRNG

No revocation: a stolen JWT is valid until it expires

The defining property of a stateless JWT is that the server does not store it — it just verifies the signature and trusts the exp claim. The flip side is the production headache: there is no built-in logout. If a token is stolen (XSS, a leaked log, a proxy), you cannot un-issue it. Calling a “logout” endpoint that deletes a client cookie does nothing to a token the attacker already copied. It stays valid until exp.

The senior mitigation is to keep the blast radius small with time. Issue short-lived access tokens — 5 to 15 minutes is the standard range — paired with a long-lived refresh token that exchanges for new access tokens. Now a stolen access token dies in minutes. The refresh token is the high-value secret, so it gets rotation: every time it’s used, the server issues a new refresh token and invalidates the old one. If an old refresh token is ever replayed (the attacker used the one you’d already rotated past), the server detects reuse and revokes the entire token family — turning a stolen refresh token into a self-defeating trap.

Why this works

“Stateless” is a spectrum, not a religion. The moment you add refresh-token rotation or a revocation list, you’ve reintroduced server-side state — and that’s fine. The win of JWTs was never “zero server state forever”; it was keeping the high-frequency check (every API request validates a signature locally) cheap, while the rare events (login, refresh, revoke) touch a store. Teams that treat statelessness as absolute end up with no way to kick out a compromised session.

Where you store the token decides which attack hits you

A token is a bearer credential: whoever holds it is you. So storage is a security decision, not a convenience one, and it’s a genuine tradeoff with no free option.

  • localStorage is reachable by any JavaScript on the page. One XSS bug — a vulnerable dependency, an unsanitized render — and the attacker reads the token and exfiltrates it. There’s no httpOnly equivalent for localStorage.
  • httpOnly cookies are invisible to JavaScript, so XSS can’t read them — but cookies are sent automatically, which opens CSRF. You close that with SameSite=Strict (or Lax) plus Secure, and CSRF tokens for sensitive actions.
  • In-memory (a JS variable) survives neither a refresh nor a new tab, but it never touches disk and is the smallest XSS target — common for the access token, paired with an httpOnly cookie holding the refresh token.

There is no storage that’s immune to everything; you pick which class of attack you’d rather defend, and short token TTLs shrink the damage of whichever one slips through.

Pick the best fit

A SPA needs to keep a user logged in across page refreshes. Pick the token storage strategy a security-minded senior ships.

Quiz

Your server verifies tokens with jwt.verify(token, key) and reads the algorithm from the token's header. What's the most important hardening?

Quiz

A user reports their session was hijacked. With stateless JWTs, why can't you just 'log them out' server-side, and what limits the damage?

Order the steps

Order the checks a senior runs to harden a JWT verification path:

  1. 1 Pin an explicit algorithm allowlist on verify — never read alg from the header to choose the path
  2. 2 Bind each key to exactly one algorithm so a public key can't be used as an HMAC secret
  3. 3 Use a CSPRNG-generated HMAC secret of at least 256 bits (or an asymmetric key pair)
  4. 4 Set short access-token TTLs (5–15 min) so a leaked token expires fast
  5. 5 Add refresh-token rotation with reuse detection to revoke a compromised token family
Recall before you leave
  1. 01
    Walk a teammate through the RS256-to-HS256 confusion attack and the two RFC 8725 defenses that stop it.
  2. 02
    Why do stateless JWTs have no real logout, and how do short TTLs plus refresh-token rotation contain a stolen token?
Recap

Every JWT pitfall traces back to one mistake: trusting the token to describe how it should be verified. The alg:none bug accepts an empty signature; algorithm confusion turns a published RS256 public key into an HMAC secret — both are stopped by pinning an explicit algorithm allowlist on verify and binding each key to exactly one algorithm, as RFC 8725 prescribes. Because a stateless token isn’t stored, there’s no instant logout, so you contain a stolen token with short access TTLs (5–15 minutes) and refresh-token rotation with reuse detection. HMAC secrets must be at least 256 random bits so they can’t be cracked offline. And storage is a tradeoff with no free option: localStorage is XSS-readable, httpOnly cookies dodge XSS but invite CSRF (close it with SameSite + Secure), and in-memory is the smallest target — pick the attack you’d rather defend and let short TTLs shrink whatever slips through.

Continue the climb ↑JWT pitfalls: multiple-choice review
shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.