JWT pitfalls
JWTs are useful when used carefully and dangerous when used reflexively. Most JWT vulnerabilities come from weak validation or misunderstanding what JWTs guarantee.
What a JWT is and isn’t
A JWT is three base64url segments: <header>.<payload>.<signature>. The signature proves who issued it; the payload is not encrypted.
eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMiLCJleHAiOjE3MTY...} . <signature>
Decode the payload at jwt.io — it’s all there. JWTs are signed, not secret. Don’t put credit cards or PII in claims. If you need confidentiality, use JWE (encrypted JWT) — but at that point, an opaque token + server-side store is usually simpler.
The classic vulnerabilities
1. alg: none attack
Many libraries supported alg: none (signed with no signature). Forge any token, set alg: none, sign with empty string — server accepts.
# Wrong — accepts whatever alg the token claims
jwt.decode(token)
# Right — pin the algorithm
jwt.decode(token, key, algorithms=["RS256"]) # PyJWT
Modern libraries fail safe by default but old code, library bumps, or verify_signature=False flags can re-introduce it.
2. Algorithm confusion (RS256 ↔ HS256)
The token is supposed to be RS256 (asymmetric). The verification call is jwt.decode(token, public_key). Attacker forges a token with alg: HS256 and uses the public key as the HMAC secret. Some libraries accept this — public keys are public.
Defense: always specify allowed algorithms.
jwt.decode(token, public_key, algorithms=["RS256"]) # rejects HS256 forgery
Never just jwt.decode(token, key).
3. Missing audience / issuer validation
# Wrong — any RS256 token from anyone with this signer's key works
jwt.decode(token, public_key, algorithms=["RS256"])
# Right
jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience="your-api",
issuer="https://auth.example.com",
)
Without aud validation, a token meant for API X can be replayed against API Y if both trust the same issuer. Without iss validation, you might accept tokens from any issuer the library trusts.
4. No expiry validation
# Wrong
jwt.decode(token, ..., options={"verify_exp": False})
# Right — leave defaults; verify exp by default
jwt.decode(token, ..., algorithms=["RS256"])
Some libraries default-skip exp if no exp claim is present. Insist on it: options={"require": ["exp", "iss", "aud", "sub"]}.
5. No revocation mechanism
JWT verification is stateless — that’s the appeal. But it means:
- You can’t log a user out. The token works until
exp. - You can’t invalidate a stolen token. Same.
Mitigations:
- Short access token lifetime (5–15 min).
- Refresh token with rotation (see 05_oauth2_oidc.md).
- Revocation list / blocklist — keep
jtiof revoked tokens in Redis until theirexp. Check on every request. Now you’ve reintroduced state — at which point ask whether opaque tokens were better all along.
For high-security contexts (admin sessions, financial), opaque tokens + server-side introspection are usually right. JWT shines for high-throughput APIs where the trade-off (revocation lag for stateless verify) is acceptable.
6. Storing tokens insecurely
- SPAs storing JWT in localStorage: readable by any JS — XSS = full token theft.
- HttpOnly cookies: not readable by JS, but susceptible to CSRF (see 03_xss_csrf.md).
Trade-off:
| Storage | XSS risk | CSRF risk | Notes |
|---|---|---|---|
| localStorage | High — XSS reads token | None (no cookie) | Common but risky |
| HttpOnly cookie | Low | Need CSRF defense | SameSite=Lax handles most |
| In-memory + httpOnly refresh cookie | Lower | Need CSRF defense | Modern best practice for SPA |
The “in-memory” pattern: SPA holds access token in JS variable (lost on tab refresh — but that’s fine, refresh from cookie). XSS can steal in-memory token but only for that tab and only short-lived.
7. Putting too much in the payload
{
"sub": "123",
"permissions": ["read", "write", "admin", ...], # huge list
"user_data": {...},
}
JWTs travel on every request. A 4KB token sent on every API call adds up. Prefer:
- Identity in JWT (
sub). - Permissions / data in your DB / cache, looked up by
sub.
Library checklist
PyJWT (most common):
import jwt
from jwt import PyJWKClient
jwks = PyJWKClient("https://auth.example.com/.well-known/jwks.json")
def decode(token: str) -> dict:
signing_key = jwks.get_signing_key_from_jwt(token).key
return jwt.decode(
token,
signing_key,
algorithms=["RS256"],
audience="your-api",
issuer="https://auth.example.com",
options={"require": ["exp", "iss", "aud", "sub"]},
)
authlib: similar API, supports JWE if needed.
Don’t use python-jose for new code (security history is mixed; less maintained).
Signing key management
- HS256 (HMAC, symmetric): one secret. If anyone with verify access also has sign access, that’s bad. Fine for monolithic apps; problematic for multi-service.
- RS256 / ES256 (asymmetric): private key signs, public key verifies. Resource servers don’t need the private key. Use this for any non-trivial deployment.
Rotate keys. Publish JWKs at a stable URL, support multiple kid (key IDs) at once during overlap.
Interview angle
- Q: “What’s a JWT and what’s it used for?” — signed claims; stateless auth tokens.
- Q: “How do you validate a JWT?” — verify signature with pinned
algorithms=, checkiss,aud,exp,nbf. - Follow-up: “What’s the
alg: noneattack?” — token claimsalg: none, naive library accepts unsigned. Pin algorithms. - Follow-up: “How do you log a user out with JWT?” — you can’t, cleanly. Short expiries + refresh-token rotation, or maintain a blocklist.
See 05_oauth2_oidc.md, 03_xss_csrf.md, 11_authentication/jwt/.