Backend / Security / 06_jwt_pitfalls.md

JWT pitfalls

Updated 4 min read source
On this page5
  1. What a JWT is and isn’t
  2. The classic vulnerabilities
  3. Library checklist
  4. Signing key management
  5. Interview angle

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.

text
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.

python
# 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.

python
# rejects HS256 forgery
jwt.decode(token, public_key, algorithms=["RS256"])

Never just jwt.decode(token, key).

3. Missing audience / issuer validation

python
# 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

python
# 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 OAuth 2.0 and OpenID Connect).
  • 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 XSS and CSRF).

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

python
{
  "sub": "123",
  # huge list
  "permissions": ["read", "write", "admin", ...],
  "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):

python
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 4

  • 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 OAuth 2.0 and OpenID Connect, XSS and CSRF, JWT and the JOSE Family.