L25 · JWTs and Their Pitfalls
Module 4: Identity and Access · ~20 min · Lesson 25 of 71
🎯 What you'll learn
- Why a JWT's header is attacker-controlled input, and the four attacks that abuse it
- The checks a verifier must run, in order, before trusting a single claim
- Why "stateless logout" doesn't exist, and what to do instead
- Where a browser app should keep tokens (and why the best answer is "not in the browser")
🔥 Why this matters
In March 2015, Tim McLean showed that several popular JWT libraries could be fooled into accepting
tokens nobody had signed. The bug wasn't in the cryptography: the libraries let the token choose
how it should be checked. The same class of mistake keeps coming back: Node's jsonwebtoken fixed
related issues again in version 9.0.0 (2022). Gateways aren't immune: a Kong route with the jwt plugin
and an empty claims_to_verify will accept a token that expired last year.
1. Three parts, and who controls them
L17 decoded an HS256 token and showed that signing is not encrypting: anyone can read the claims. Today the question is different: what does the verifier do with each part?
eyJhbGciOiJSUzI1NiIs... . eyJpc3MiOiJodHRwczov... . Xk2pQ...
└──── HEADER ────────┘ └──── PAYLOAD ─────────┘ └ SIG ┘
{"alg":"RS256", {"iss":"https://auth. sign(
"typ":"at+jwt", example.com", header
"kid":"k-2026-11"} "sub":"C-30582", + "." +
"aud":"policy-api", payload)
"exp":1793974500,
"jti":"7f3a-91c2"}
│ │ │
▼ ▼ ▼
tells the verifier the facts you act on proves the
HOW to check the token AFTER checks pass first two parts
(attacker can edit it) weren't changed
The trap is in the left column. Until the signature is checked, the header is just text the
client sent, and a library that obeys its alg or kid lets the attacker choose the lock.
What you see above is a JWS (JSON Web Signature, RFC 7515): signed, readable, three parts. A JWE (JSON Web Encryption, RFC 7516) has five parts and its payload is encrypted. Most access tokens are JWS; use JWE only when claims must stay hidden from the client.
The registered claims (RFC 7519) are the ones you validate:
| Claim | Meaning | Check |
|---|---|---|
iss |
Issuer: who minted it | Exactly your IdP's URL |
aud |
Audience: who it's for | Contains your API |
exp |
Expiry time (Unix seconds) | In the future |
nbf |
Not before | In the past |
iat |
Issued at | Sanity only |
jti |
Unique token ID | Used for denylists and replay checks |
2. Four ways to abuse the header
Header the attacker sends What a naive verifier does
───────────────────────────────── ───────────────────────────────
a) "alg":"none" skips the signature check
b) "alg":"HS256" (key is RSA) uses the PUBLIC key as an HMAC
secret the attacker also knows
c) "kid":"../../../dev/null" loads an empty file as the key
"kid":"x' UNION SELECT 'k'--" runs it into a SQL key lookup
d) "jku":"https://evil.example/k" fetches the attacker's keys
"jwk":{...attacker's key...} verifies with the key provided
a) alg: none. RFC 7518 defines none for an unsecured JWS, with no signature at all.
The attacker edits the payload, sets "alg":"none", and sends an empty signature. Never accept it.
b) Algorithm confusion. Your API expects RS256 tokens and has the issuer's public key. The
attacker writes a token with "alg":"HS256" and computes an HMAC using that public key as the
secret. A library that does "HS256 → HMAC with the key I was given" verifies it. This was
CVE-2015-9235 in Node's jsonwebtoken before 4.2.2. The first deep dive walks through it.
c) kid injection. The kid (key ID) header says which key signed the token. If your code
builds a file path or SQL query from it, the attacker picks the key. /dev/null gives an empty
key they can sign with.
d) jku, x5u and jwk. These headers point to a key set URL, a certificate URL, or carry a
key inline. Following them trusts whatever key the attacker supplies (Cisco's node-jose accepted
embedded keys: CVE-2018-0114). Fetching an attacker's URL is also SSRF (L35).
One more: weak HS256 secrets. Any captured HS256 token lets an attacker guess the secret
offline, as fast as their GPUs allow (hashcat has a JWT mode, -m 16500). RFC 7518 requires an
HS256 key of at least 256 bits, and RFC 8725 says a human-memorable password must never be one.
3. Verifying a JWT properly
RFC 8725 (JWT Best Current Practices, 2020; a revision was in the RFC Editor queue in late 2026) boils down to one rule: the server decides how to verify; the token only gets to say which of the server's keys to use.
token ──▶ 1. parse; reject if not 3 parts or bad base64url
2. alg in MY allow-list for this key? (no "none")
3. kid → key from MY JWKS, fetched from MY config
(a map lookup: never a path, query, or URL)
4. verify the signature with that key
5. typ is what I expect (e.g. at+jwt, RFC 9068)
6. iss == my IdP, aud contains me
7. exp > now, nbf <= now (leeway ~30-60 s)
8. THEN authorize: scope, roles, object owner (L27)
│ any step fails
▼
401, and log why (without the token)
Steps 2 and 3 defeat every attack in section 2. Step 6 stops cross-service replay: a token
issued for claims-api mustn't work on policy-api just because both trust the same IdP. Don't
hand-write this: configure a maintained library strictly.
4. Expiry and revocation: the "stateless" myth
A JWT is valid until exp, whatever happens on the server. If a customer logs out, changes their
password, or reports a stolen phone, tokens already issued keep working. "Stateless logout" only
deletes the client's copy; a thief's copy still works.
The standard design limits the damage:
time ──────────────────────────────────────────────────────▶
access token [─15m─][─15m─][─15m─][─15m─] short, a JWT,
▲ ▲ ▲ ▲ sent to APIs
refresh token [════════ hours to days ═══════════]
each refresh returns a NEW refresh token
(rotation); reusing an old one = theft
→ revoke the whole family
- Short-lived access tokens (5-15 minutes) cap how long a stolen one is useful.
- Refresh tokens go only to the authorization server, which keeps state and can revoke them. RFC 9700 (OAuth Security BCP, 2025) requires refresh tokens for public clients to be sender-constrained or rotated.
- For emergencies, a denylist by
jtichecked on each request. That's server-side state again, which is the point: real revocation needs state somewhere. The second deep dive compares options.
5. Where should a browser keep tokens?
| Storage | XSS can steal it? | Notes |
|---|---|---|
localStorage / sessionStorage |
Yes: any script reads it | Easiest, and the most common mistake |
| JavaScript memory | Harder, but XSS can still use it | Lost on page reload |
HttpOnly; Secure; SameSite cookie |
No (JS can't read it), but XSS can send requests with it | Needs CSRF thinking (L22) |
| BFF: tokens stay on the server | Nothing to steal in the browser | Browser holds only a session cookie |
A Backend for Frontend (BFF) is a small server-side component of your web app that does the OAuth flow, keeps the tokens, and calls APIs for the browser. RFC 10017 (OAuth 2.0 for Browser-Based Applications, August 2026) calls a BFF "strongly recommended" for business and sensitive apps and ones that handle personal data, and advises against browser-only OAuth clients for them. A customer portal showing policy and payment data is exactly that case.
🧪 Worked example: a policy API that trusts too much
policy-service (Python, behind Kong) checks tokens from https://auth.example.com. Before:
import jwt # PyJWT 2.x
def get_claims(token):
alg = jwt.get_unverified_header(token)["alg"] # ✗ token picks
return jwt.decode(token, PUBLIC_KEY,
algorithms=[alg]) # ✗ no aud/iss
Problems: the token picks its own algorithm (PyJWT's docs warn against exactly this), nothing
checks iss or aud, and exp is checked only if present, so a token without one never
expires. PyJWT does refuse PEM public keys as HMAC secrets (its CVE-2017-11424 and CVE-2022-29217
fixes), but don't rely on a library guard. After:
import jwt
JWKS = jwt.PyJWKClient(
"https://auth.example.com/.well-known/jwks.json") # from config
def get_claims(token):
key = JWKS.get_signing_key_from_jwt(token) # kid → trusted set
return jwt.decode(
token, key.key,
algorithms=["RS256"], # pinned
issuer="https://auth.example.com",
audience="policy-api",
leeway=30,
options={"require": ["exp", "iat", "sub", "jti"]},
)
Node's equivalent is jwt.verify(token, getKey, { algorithms: ['RS256'], issuer, audience }).
At the gateway, Kong's jwt plugin pins the algorithm per credential and rejects anything else
(Invalid algorithm), but it checks exp only if you ask:
plugins:
- name: jwt
config:
key_claim_name: iss # default; picks the credential
claims_to_verify: [exp, nbf] # no default: unset = no expiry!
maximum_expiration: 900 # reject tokens living > 15 min
consumers:
- username: auth-example
jwt_secrets:
- key: https://auth.example.com # must equal the iss claim
algorithm: RS256 # default would be HS256
secret: unused # required field, dummy value
rsa_public_key: |
-----BEGIN PUBLIC KEY-----
(made-up key goes here)
-----END PUBLIC KEY-----
Kong's jwt plugin doesn't check aud and doesn't read a JWKS URL, so the service still checks
the audience (defense in depth, L04).
🛠 Try it (optional, ~5 min)
All local. A made-up HS256 token (claims invented, secret deliberately weak). Paste into a shell:
T='eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.'\
'eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJDLTMwNTgy'\
'IiwiYXVkIjoicG9saWN5LWFwaSIsInNjb3BlIjoicG9saWNpZXM6cmVhZCIsImlh'\
'dCI6MTc5Mzk3MzYwMCwiZXhwIjoxNzkzOTc0NTAwLCJqdGkiOiI3ZjNhLTkxYzIi'\
'fQ.4if1NVM4PbdM_XroQrUZ5agb5-BPwy-C7mkyKlNmaLA'
b64url_decode() { # base64url → base64, then add padding
local s; s=$(printf '%s' "$1" | tr '_-' '/+')
while [ $(( ${#s} % 4 )) -ne 0 ]; do s="$s="; done
printf '%s' "$s" | base64 -d
}
1. The naive way first. The payload is 195 characters, not a multiple of 4, because JWTs
strip = padding:
$ printf '%s' "$T" | cut -d. -f2 | base64 -d
{"iss":"https://auth.example.com", ... ,"jti":"7f3a-91c2"
On macOS the closing } is silently dropped (exit code 0), so jq fails with Unfinished JSON term. GNU base64 on Linux prints it all but exits 1 with invalid input, and also rejects
base64url's - and _ (macOS accepts them). The helper handles both.
2. Decode properly:
$ b64url_decode "$(printf '%s' "$T" | cut -d. -f1)"
{"alg":"HS256","typ":"JWT"}
$ b64url_decode "$(printf '%s' "$T" | cut -d. -f2)" | jq .
{
"iss": "https://auth.example.com",
"sub": "C-30582",
"aud": "policy-api",
"scope": "policies:read",
"iat": 1793973600,
"exp": 1793974500,
"jti": "7f3a-91c2"
}
$ date -u -r 1793974500 # Linux: date -u -d @1793974500
Fri Nov 6 14:15:00 UTC 2026
A 15-minute token. Look for what a verifier would check: iss, aud, exp, jti.
3. Guess the secret offline, like an attacker with a captured token would (a five-word "wordlist"; real ones have billions of entries):
DATA=${T%.*}; SIG=${T##*.}
for k in secret changeme password1 summer2026 jwt-key; do
s=$(printf '%s' "$DATA" | openssl dgst -sha256 -hmac "$k" -binary \
| base64 | tr '+/' '-_' | tr -d '=')
[ "$s" = "$SIG" ] && echo "secret is: $k" && break
echo "not: $k"
done
not: secret
not: changeme
not: password1
secret is: summer2026
No server was contacted, and now the attacker can mint any token. (Works with Homebrew OpenSSL and macOS's LibreSSL.)
🧠 Key takeaways
- The JWT header is untrusted input: never let
alg,kid,jkuorjwkdecide how you verify. - Pin the algorithm per key, take keys only from your own JWKS, then check
iss,aud,exp,nbf. - HS256 secrets need 256+ random bits; one captured token is enough to brute-force a weak one.
- JWTs can't be un-issued: use short access tokens, rotated refresh tokens, and a
jtidenylist for emergencies. - In browsers, prefer a BFF; never
localStoragefor tokens that reach financial or personal data.
📖 Words to know
| Term | Meaning |
|---|---|
| JWS | A signed JWT: header.payload.signature, readable by anyone |
| JWE | An encrypted JWT with five parts; the payload is hidden |
| Unsecured JWS | A token with "alg":"none" and no signature; never accept it |
| Algorithm confusion | Tricking a verifier into using a different algorithm than the key was meant for, e.g. RS256 → HS256 |
kid |
Key ID header: which of the issuer's keys signed the token |
jku / x5u / jwk |
Headers that point to, or carry, a key; don't follow them |
| Refresh token rotation | Each refresh returns a new refresh token; reuse of an old one signals theft |
jti denylist |
A server-side list of revoked token IDs, kept until each token's exp |
| BFF (Backend for Frontend) | A server-side part of a web app that holds tokens so the browser never sees them |
⏭ Next time
L26: SAML and enterprise SSO, the XML-based cousin of these tokens, where signature-wrapping attacks and "Golden SAML" show what happens when an assertion's signature covers the wrong thing.
Algorithm confusion step by step
The setup
Your API verifies RS256 tokens. It gets the issuer's public key from a JWKS, which is public by design (anyone can download it; that's what lets many services verify). The attacker has no private key and no secrets, only that public key.
The attack
Attacker Vulnerable verifier
──────── ───────────────────
1. GET /.well-known/jwks.json ─────────▶ public key (PEM)
2. header {"alg":"HS256"}
payload {"sub":"C-30582","role":"admin"}
3. sig = HMAC-SHA256(key = PUBLIC KEY BYTES,
msg = header.payload)
4. send token ─────────────────────────▶ reads alg = HS256
key = "the key I have"
= public key bytes
HMAC(pubkey, h.p) == sig
✔ "valid", role=admin
Why it works: HMAC (L17) accepts any bytes as a key. The verifier was written as
verify(token, key), with one key variable that could be an RSA key or an HMAC secret
depending on alg. Once the attacker picks alg, the public key turns into a shared secret, and
everyone has it. It's a MAC-vs-signature mix-up (L17) done by the attacker.
🧪 Worked example: reproduce it locally
A condensed version of a script you can run with Python's cryptography package (key generated
on your machine, no network):
import base64, hashlib, hmac, json
from cryptography.hazmat.primitives import serialization as ser
from cryptography.hazmat.primitives.asymmetric import rsa
b64u = lambda b: base64.urlsafe_b64encode(b).rstrip(b"=")
priv = rsa.generate_private_key(public_exponent=65537,
key_size=2048)
PUB = priv.public_key().public_bytes(
ser.Encoding.PEM, ser.PublicFormat.SubjectPublicKeyInfo)
# the attacker knows only PUB
h = b64u(json.dumps({"alg": "HS256"}).encode())
p = b64u(json.dumps({"sub": "C-30582", "role": "admin"}).encode())
sig = b64u(hmac.new(PUB, h + b"." + p, hashlib.sha256).digest())
forged = h + b"." + p + b"." + sig
def naive_verify_hs256(token, key): # the branch the attacker hits
hh, pp, ss = token.split(b".")
good = b64u(hmac.new(key, hh + b"." + pp,
hashlib.sha256).digest())
return hmac.compare_digest(ss, good)
print(naive_verify_hs256(forged, PUB)) # True
The full version (with an RS256 branch and a pinned verifier) printed:
naive : {'sub': 'C-30582', 'role': 'admin'}
pinned: None
The pinned verifier just checked alg == "RS256" before anything else. That one line is the fix.
How libraries defend today
| Library | Default defence | What you should still do |
|---|---|---|
| PyJWT 2.x | algorithms is required; refuses PEM keys as HMAC secrets |
Pass one algorithm, plus issuer, audience, require |
jsonwebtoken (Node) 9.x |
No none unless listed; default algorithms follow the key type |
Pass algorithms: ['RS256'] anyway |
| nimbus-jose-jwt (Java) | Key selector bound to an algorithm | Use JWSVerificationKeySelector with one alg |
Kong jwt plugin |
alg must equal the credential's algorithm |
Set algorithm explicitly; the default is HS256 |
For Java, nimbus-jose-jwt (used under Spring Security's resource server) makes the binding explicit. Sketch, based on Connect2id's published example (v9.28+ API):
var keys = JWKSourceBuilder.create(new URL(JWKS_URL)).build();
var proc = new DefaultJWTProcessor<SecurityContext>();
proc.setJWSTypeVerifier(new DefaultJOSEObjectTypeVerifier<>(
new JOSEObjectType("at+jwt")));
proc.setJWSKeySelector(new JWSVerificationKeySelector<>(
JWSAlgorithm.RS256, keys)); // alg pinned to keys
proc.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>(
"policy-api", // required audience
new JWTClaimsSet.Builder()
.issuer("https://auth.example.com").build(),
Set.of("sub", "iat", "exp", "jti"))); // required claims
JWTClaimsSet claims = proc.process(token, null);
With jjwt, Jwts.parser().verifyWith(publicKey) ties the check to the key's type, and
parseSignedClaims rejects unsigned tokens.
A cousin: when the library itself is wrong
In April 2022, Neil Madden found that Java 15 to 18's ECDSA verifier accepted a signature of all zeros for any message (CVE-2022-21449, "Psychic Signatures"). An ES256 JWT with an empty-looking signature verified on unpatched JVMs. Oracle's early advisory also listed older versions, and the affected range was disputed; the fix shipped in the April 2022 Critical Patch Update. The lesson: pinning algorithms protects against confusion, but you still need to patch your runtime (L02's KEV and EPSS help you decide how fast).
🧠 Extra takeaways
- Algorithm confusion turns a public key into a shared secret by switching RS256 to HS256.
- Bind each key to exactly one algorithm; reject tokens whose
algdoesn't match before verifying. - Modern libraries have guards, but pass the algorithm list explicitly anyway.
- Runtime crypto bugs (like CVE-2022-21449) bypass correct code: patch the JVM too.
| Term | Meaning |
|---|---|
| Key confusion | Using a key with an algorithm it wasn't meant for (another name for algorithm confusion) |
| Key selector | Code that picks a verification key and its allowed algorithm from trusted config |
| Psychic Signatures | CVE-2022-21449: Java 15-18 accepted all-zero ECDSA signatures |
Revoking a JWT: options and trade-offs
The problem
A customer calls: their phone was stolen while logged into the app. The setup: 15-minute JWT
access tokens and 30-day refresh tokens. Kong and policy-service verify JWTs locally, without
calling the IdP. What can you actually cut off, and how fast?
The options
| Option | Cuts off a stolen access token? | Cost |
|---|---|---|
Wait for exp |
After up to 15 min | None, if tokens are short |
| Revoke the refresh token (family) | Stops new access tokens | One record at the auth server |
jti denylist |
Immediately | A lookup on every request |
| "Valid after" per user | Immediately, all that user's tokens | A lookup per request (by sub) |
| Introspection (RFC 7662) or opaque tokens | Immediately | A call to the auth server per request (cacheable) |
| Rotate the signing key | Immediately, for everyone | Logs out every user |
There's no free option: every "immediately" row puts state back into verification. The real design question is how much state, and where.
Refresh token rotation with reuse detection
Phone (owner) Auth server Thief (copy of RT1)
───────────── ─────────── ───────────────────
RT1 ──refresh────────▶ RT1 ok → mark used
◀──AT2 + RT2─────
◀──── refresh with RT1
RT1 already used!
→ revoke RT2 and the
whole family
RT2 ──refresh────────▶ revoked → log in again
────▶ 401
The auth server can't tell which party is the thief, so it kills the family and forces a fresh login (with MFA, L21). For public clients such as mobile apps and SPAs, RFC 9700 requires either this rotation or sender-constrained refresh tokens (bound to a key the client holds).
🧪 Worked example: a jti denylist in MongoDB
For the stolen phone, support revokes the user's refresh tokens and adds any access tokens still live to a denylist. A TTL index cleans up each entry once the token would have expired anyway:
// mongosh: one-time setup
db.revoked_tokens.createIndex(
{ expiresAt: 1 }, { expireAfterSeconds: 0 }) // delete at exp
db.revoked_tokens.createIndex({ jti: 1 }, { unique: true })
// on revocation (made-up IDs)
db.revoked_tokens.insertOne({
jti: "7f3a-91c2", sub: "C-30582", reason: "device stolen",
expiresAt: new Date(1793974500 * 1000) }) // token's exp
# in get_claims(), after jwt.decode() succeeds
if revoked.find_one({"jti": claims["jti"]}, {"_id": 1}):
raise PermissionError("token revoked") # → 401
Notes: the list stays tiny because entries only live until exp, and with 15-minute tokens it's
rarely needed at all. MongoDB's TTL monitor runs about once a minute, so deletion lags a little;
that's harmless here because the token has already expired. Kong's jwt plugin can't consult
this list, so the check lives in the service (or a cache such as Redis that both read).
Picking a design
Is a 15-min window acceptable for this API?
├─ yes → short ATs + rotated refresh tokens. Done.
└─ no (payments, beneficiary changes, admin actions)
├─ few revocations → jti denylist / valid-after, cached
└─ must be exact → introspection or opaque tokens
for these routes only
You can mix: plain JWT checks for GET /v1/policies, introspection for
POST /v1/payments. Also revoke on the events that matter: password change, MFA reset, account
lock, and "log out everywhere".
🛠 Try it (~5 min, thought exercise)
For one API your team owns, write down the worst thing a stolen access token could do in 15 minutes. Is that acceptable? If not, which row of the options table would you pick, and where would the check run: the gateway, the service, or both?
🧠 Extra takeaways
- Every real revocation mechanism is server-side state; JWTs just let you choose how little.
- Short access tokens plus rotated refresh tokens cover most cases; reuse detection catches theft.
- A
jtidenylist with a TTL equal toexpstays small and cheap. - Use stronger, stateful checks only on high-risk routes.
| Term | Meaning |
|---|---|
| Token family | All refresh tokens descended from one login; revoked together on reuse |
| Reuse detection | Treating a second use of a rotated refresh token as theft |
| Token introspection | Asking the auth server whether a token is still active (RFC 7662) |
| Opaque token | A random reference string with no readable claims; only the issuer can resolve it |
| TTL index | A MongoDB index that deletes documents automatically after a date |
The quiz for this lesson and the Tests section need JavaScript.