backend / security / 06_jwt_pitfalls.md

JWT pitfalls

4 min read source

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 jti of revoked tokens in Redis until their exp. 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=, check iss, aud, exp, nbf.
  • Follow-up: “What’s the alg: none attack?” — token claims alg: 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/.