The Algorithm Confusion Attack That Broke Auth0
In 2022, security researchers discovered that a misconfigured JWT library let attackers change the algorithm in the JWT header from RS256 (asymmetric) to HS256 (symmetric). The server's public key, meant to be shared openly, was suddenly being used as the HMAC secret. Since that public key was, well, public, anyone could forge valid JWTs and access any account they wanted. The bug affected libraries in Node.js, Python, Ruby, and PHP, and it remains a masterclass in why implementation details matter so much for JWT authentication.
JWT Anatomy
JWT Structure (three Base64URL-encoded parts):
eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyNDIiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3MDUzMTI4MDB9.dG9rZW5fc2lnbmF0dXJl
|___ Header ___| |__________________ Payload ___________________| |___ Signature ___|
Header (decoded):
{
"alg": "RS256", // Signing algorithm
"typ": "JWT", // Token type
"kid": "key-2024-01" // Which key to use for verification
}
Payload (decoded):
{
"sub": "user42", // Subject (who)
"role": "admin", // Custom claim (what permissions)
"iss": "auth.myapp.com", // Issuer (who created this)
"aud": "api.myapp.com", // Audience (who should accept this)
"iat": 1705309200, // Issued at (when created)
"exp": 1705312800 // Expires (when it dies) - 1 hour later
}
Signature:
RS256( base64(header) + '.' + base64(payload), PRIVATE_KEY )
WARNING: The payload is NOT encrypted!
base64decode('eyJzdWIiOiJ1c2VyNDIi...') = readable JSON
NEVER put passwords, SSNs, or secrets in JWT claims.Stateless vs Stateful Authentication
The difference between session-based and JWT-based authentication comes down to where the source of truth lives, in the database or in the token itself. That single design choice ripples through every request your server handles.
Session-Based (Stateful):
Client Server Database
|--POST /login-------->| |
| |--INSERT session--------->|
| |<--session_id='abc123'----||
|<--Cookie: sid=abc123-| |
| | |
|--GET /api (sid=abc)-->|--SELECT * FROM sessions->|
| | WHERE id='abc123' |
| |<--user_id=42, role=admin--|
|<--200 OK-------------| |
Every request = 1 database query. 10K req/s = 10K DB lookups/s.
JWT-Based (Stateless):
Client Server
|--POST /login-------->|
| |--Sign JWT with private key
|<--JWT token----------| (CPU operation, no DB)
| |
|--GET /api (JWT)----->|
| |--Verify signature (CPU only)
| | Decode claims: user=42, role=admin
|<--200 OK-------------| No database needed!
Every request = 1 CPU operation. Scales horizontally.
Any server with the public key can verify any token.That 10K-lookups-per-second number is exactly the kind of bottleneck that pushes teams toward caching layers for session data, or toward stateless JWT authentication altogether. If you're weighing where to put that state, it's worth reading through caching strategies, invalidation, and placement trade-offs before you commit to an architecture.
The Revocation Problem and Solutions
The Revocation Dilemma:
User changes password at 10:00 AM
JWT issued at 9:55 AM, expires at 10:10 AM
The JWT is STILL VALID for 10 more minutes!
(Stateless = server cannot 'un-issue' a token)
Solution: Access + Refresh Token Pattern
Timeline:
|----access token (15 min)----|
| |----access token (15 min)----|
|=============refresh token (7 days)========================|
Access Token: Refresh Token:
- Short-lived (5-15 min) - Long-lived (7-30 days)
- Sent with every API request - Sent ONLY to /token/refresh
- Stored in memory (JS variable) - Stored in HTTP-only cookie
- If stolen: 15 min damage max - If stolen: detected via rotation
- Stateless verification - Checked against databaseRefresh Token Rotation
This is the piece that turns a JWT refresh token from a liability into a theft-detection mechanism. Each refresh token can only be used once, and reuse is treated as a red flag rather than a normal request.
Refresh Token Rotation (Theft Detection):
Normal flow:
Client: POST /token/refresh { refresh_token: 'RT-1' }
Server: Invalidate RT-1
Issue new: { access_token: 'AT-2', refresh_token: 'RT-2' }
Client: POST /token/refresh { refresh_token: 'RT-2' }
Server: Invalidate RT-2
Issue new: { access_token: 'AT-3', refresh_token: 'RT-3' }
Theft scenario:
Attacker steals RT-2 and uses it:
Attacker: POST /token/refresh { refresh_token: 'RT-2' }
Server: RT-2 already used! THEFT DETECTED!
Invalidate ENTIRE token family
Force user to re-login
The key insight: each refresh token is single-use.
If a used token appears again, someone stole it.HS256 vs RS256: Choosing the Right Algorithm
Once you move past a single monolith and start splitting things into services, especially behind an API gateway or BFF layer, the choice between HS256 and RS256 stops being academic and starts mattering a lot.
Algorithm Selection:
HS256 (Symmetric - HMAC + SHA-256):
Same secret key signs AND verifies
+ Faster (pure hash operation)
- Every service that verifies needs the secret
- If any service is compromised, attacker can forge tokens
Best for: single-service architectures
RS256 (Asymmetric - RSA + SHA-256):
Private key signs, public key verifies
+ Only auth service has the private key
+ Any service can verify with the public key
+ Compromised service cannot forge new tokens
- Slower (~10x) than HS256
Best for: microservices, multi-service architectures
Rule: If more than one service verifies tokens, use RS256.JWT Security Checklist
Always validate the exp, iss, and aud claims, and reject tokens that are missing them.
Use RS256 for multi-service architectures. Never share signing secrets across services.
Enforce the algorithm in code. Never trust the alg header from the token itself (this is exactly what prevents algorithm confusion attacks).
Store refresh tokens in HTTP-only, Secure, SameSite=Strict cookies, never in localStorage, which is vulnerable to XSS.
Keep access tokens short-lived (5-15 minutes) to limit the blast radius of a stolen token.
Never put sensitive data in the payload. It's Base64-encoded, not encrypted, so anyone can read it.
Implement refresh token rotation so you can detect theft the moment a used token reappears.
Interview Tip
JWT questions are almost guaranteed in system design interviews. Draw the access + refresh token flow and explain the tradeoff: JWTs eliminate the session database bottleneck but sacrifice instant revocation. Mention refresh token rotation as the theft detection mechanism. When asked "how do you handle logout?", explain three options: (1) short-lived access tokens make logout less critical, (2) a token blacklist in Redis for immediate revocation, (3) a per-user token version counter that invalidates all tokens on password change. The strongest answer combines all three.
If you want more practice framing these tradeoffs out loud, the low-level design fundamentals guide and our roundup of system design learning platforms are both good places to keep sharpening this before an interview.
Key Takeaway
JWTs enable stateless authentication at scale but cannot be revoked once issued. Combine short-lived access tokens (5-15 min) with rotating refresh tokens to get both scalability and security. Use RS256 for microservices. And never, ever store sensitive data in the payload, it's readable by anyone who bothers to decode it.
