Skip to main content
JWT (JSON Web Token)lesson 4 of 4 · 3 min read

JWT Mistakes That Get Exploited

The algorithm called none

The flexibility of this format is an attacker's favourite feature, and the famous case is the algorithm called none.

The specification allows an unsigned token. In 2015 a researcher showed that several major libraries accepted a token whose header claimed none and whose signature was empty, treating verification as passed.

Forging a session became this easy: decode the payload, edit the user identifier, set the algorithm to none, strip the signature. Libraries patched it, and the lesson generalises.

Recognise the general form. That header is attacker-controlled input, and letting it choose your verification algorithm means running the attacker's instructions. Pin an allowed list of algorithms on your side, always.

Meet its subtler cousin, key confusion. Your service verifies with a public key, so the attacker crafts a token signed with a shared-secret algorithm, using your public key, published by design, as that secret.

A library that lets the header pick the family will verify that forgery with the very key you published. Same root cause, same fix: your verifier decides the algorithm, never the token.

What actually fills pentest reports

Expect the unglamorous findings that actually fill penetration test reports. Missing audience and issuer checks, so a token issued by the same provider for a different app logs into yours.

Tokens parked in browser storage, where any script injection reads and exfiltrates them, when cookies marked unreadable by script exist precisely so that cannot happen. Tokens with no expiry at all, immortal credentials that then back up into your log aggregator forever. And weak shared secrets, where a dictionary word falls to an offline cracker at millions of guesses a second against a token the attacker already holds.

Not one of those is a cryptography failure. The algorithms have not been broken. Every item is a check somebody skipped. That is why use a maintained library and pin the algorithm beats any exotic hardening you could invent.

the shape of it
AttackerEdited payloadsub: your idalg set to nonesignature strippedLibrary acceptsverification passed1. decode, edit2. picks the alg3. header trusted
step 1 of 3
The header is attacker input, so letting it pick the algorithm runs their instructions.

Worked example

Tim McLean's March 2015 disclosure, published with Auth0, walked through the attack against then-current libraries including pyjwt and php-jwt. The steps fit in a tweet: take any valid token from the target, base64-decode the middle segment, change sub from your ID to the admin's, rewrite the header to {"alg":"none"}, re-encode, and delete everything after the second dot. Vulnerable libraries returned verified on that string, because the spec technically permits unsigned tokens and the code obligingly honored the header's choice. Patches landed within days, and the fix shaped every modern API: jsonwebtoken, pyjwt, and friends now require callers to pass an explicit algorithms allowlist, and refuse none unless you opt in loudly. Ten years later, algorithm confusion still reappears in new libraries often enough that it stays in every pentester's first-hour checklist.

JWT (JSON Web Token): wrapping up

In the real world

  • 01Google's OIDC ID tokens are RS256 JWTs; the public keys sit at a well-known JWKS URL and rotate frequently, which is why every client library caches by kid instead of hardcoding keys.
  • 02GitHub Apps authenticate by signing their own RS256 JWT, capped at 10 minutes of validity, and exchanging it for a scoped installation access token.
  • 03Kubernetes service account tokens are JWTs; since v1.21 they are bound tokens with expiry and audience, replacing the older non-expiring secrets that leaked in countless clusters.
  • 04AWS Application Load Balancer and API Gateway can validate JWT signatures and claims at the edge, rejecting bad tokens before they reach your service.
  • 05The 2015 alg-none disclosure by Tim McLean forced nearly every JWT library to require an explicit algorithm allowlist, the API shape you see in jsonwebtoken and pyjwt today.

Questions people ask

Can I store sensitive data in a JWT?

No. The payload is base64url-encoded, not encrypted, so anyone holding the token reads it in one line of code. Keep claims to identifiers, expiry, audience, and a coarse role. If you truly need encrypted claims, JWE exists, but at that point a plain opaque session ID plus a server-side lookup is usually simpler and safer.

How do I log a user out if JWTs can't be revoked?

Use short-lived access tokens, 5 to 15 minutes, paired with a server-side refresh token, and delete the refresh token on logout. The user's current access token dies within minutes on its own. If a specific route can't tolerate even that window, check a small denylist of revoked token IDs in Redis for that route only.

Should I use HS256 or RS256?

One service signing and verifying its own tokens can use HS256 and keep the secret in one place. The moment multiple services verify the same tokens, switch to RS256 or ES256, because with HS256 every verifier can also forge. Asymmetric signing plus a JWKS endpoint also makes key rotation routine instead of a coordinated fleet-wide event.

Quick review

Structure:
base64url(header).base64url(payload).signature. Three dot-separated parts
Header:
algorithm (HS256 shared secret, RS256 asymmetric). Payload: claims (sub, exp, iat, roles)
Signature ensures payload wasn't tampered with. Server verifies without DB lookup. Scales horizontally
Expiry:
access token short-lived (15 min). Refresh token long-lived (7 to 30 days) stored in httpOnly cookie
Revocation problem:
JWTs can't be invalidated before expiry without a blocklist (Redis SET of revoked JIDs)
HS256 vs RS256:
HS256 = one shared secret (all services can forge tokens). RS256 = private key signs, public key verifies
Never store JWT in localStorage. Vulnerable to XSS. Use httpOnly + SameSite=Strict cookies
the trade-off

Can't revoke mid-lifetime without extra infrastructure. Payload is base64 encoded, not encrypted. Don't put secrets in it.

in the room

Stateless microservices, API authentication, mobile apps. Where you can't do DB session lookup on every request.