Warming up the neural circuits...
By the end of this chapter you will:
JWT (JSON Web Token) is the most widely used authentication token format — and the most widely misused. Most online tutorials teach JWT wrong: no refresh tokens, no revocation, secrets in code,
alg: nonevulnerabilities. This chapter teaches JWT the way production systems actually use it.
A JWT is like a sealed, tamper-proof envelope. Inside is a note that says "Bearer: Alice, Role: user, Expires: 5pm." Anyone can read the note (it's base64-encoded, not encrypted). But the seal (the signature) proves it came from your server and hasn't been tampered with. If someone tries to change "Role: user" to "Role: admin," the seal won't match and the token is rejected.
The seal works because only your server has the signing key. Anyone can verify the seal (using the public key), but only you can create it.
A JWT is three base64url-encoded strings separated by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsInJvbGUiOiJ1c2VyIiwiZXhwIjoxNzE1NjAwMDAwfQ.abc123def456
|___________HEADER_____________|___________________PAYLOAD______________________|_____SIGNATURE_____|Header: Algorithm + token type.
{ "alg": "HS256", "typ": "JWT" }Payload (claims):
{
"sub": "42", // subject (user ID)
"role": "user", // custom claim
"iat": 1715596400, // issued at
"exp": 1715600000 // expiration (1 hour)
Signature: HMACSHA256(base64(header) + "." + base64(payload), secret)
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET; // at least 32 random bytes, base64 encoded
// Sign (create)
function createToken(userId, role) {
return jwt
| Feature | HS256 (HMAC) | RS256 (RSA) |
|---|---|---|
| Keys | One shared secret | Private key (sign) + Public key (verify) |
| Who can sign? | Anyone with the secret | Only the holder of the private key |
| Who can verify? | Anyone with the secret | Anyone with the public key |
| Best for | Single service ( ↔ same API) | Microservices (one auth service, many API services) |
| Key rotation | Must change secret everywhere | Rotate public key; private key stays on auth service |
// RS256 — auth service signs, API services verify with public key
const privateKey = fs.readFileSync('private.pem');
const publicKey = fs.readFileSync('public.pem');
// Auth service: sign with private key
const token = jwt.
RS256's killer feature: API services only need the public key to verify tokens. The private key stays on one auth service. If an API service is compromised, the attacker can verify tokens but cannot create new ones. With HS256, compromising any service gives the attacker the shared secret — they can sign tokens as anyone.
JWTs should be short-lived (15 minutes to 1 hour). If a JWT is stolen, the attacker has a limited window. But forcing users to log in every 15 minutes is terrible UX. Enter refresh tokens.
1. User logs in → server returns:
- Access token (JWT, 15 min expiry)
- Refresh token (opaque random string, 7 day expiry, stored in DB)
2. User makes API request → sends access token in Authorization header
- Server verifies JWT signature + expiry (no DB query needed!)
3. Access token expires → client sends refresh token to /auth/refresh
- Server looks up refresh token in DB
- If valid + not revoked → issues new access token + new refresh token
- If revoked/expired → 401, user must log in again
4. Refresh token rotation: every /auth/refresh issues a NEW refresh token
- Old refresh token is invalidated
- If a stolen refresh token is used, the legitimate user's next refresh attempt
fails → server detects token reuse → revokes ALL tokens for that user// Simplified refresh token implementation
const crypto = require('crypto');
async function login(req, res) {
const user = await authenticateUser(req.body.email, req.
"JWTs can't be revoked" is the most repeated myth. Here's how:
| Method | How | Tradeoff |
|---|---|---|
| Short expiry + refresh tokens | Access tokens expire in 15 min. Revoke refresh token in DB. | Adds a DB lookup on refresh. Acceptable (1 query per 15 min per user). |
| Token blocklist | Store revoked JWT IDs (jti) in Redis with TTL matching the token's remaining life. | Adds a Redis lookup per request. Fast (~0.5ms). |
| User version/epoch | Store a token_version on the user. Increment on logout/password change. JWT includes the version. | JWT must include version claim. DB lookup per request OR in Redis. |
// Blocklist approach (Redis)
async function revokeToken(jti, expiresAt) {
const ttl = Math.ceil((expiresAt * 1000 - Date.now()) / 1000);
await redis.set(`blocklist:
JWT is not always the answer:
| Scenario | JWT appropriate? | Better choice |
|---|---|---|
| Server-rendered web app (no SPA) | ❌ JWT stored in JS is vulnerable to XSS | Session cookies (HttpOnly, Secure, SameSite) |
| Real-time app (WebSockets) | ⚠️ Works but token in query string leaks to logs | Session cookie (automatically sent on WS upgrade) |
| Extremely sensitive data (banking) | ❌ JWT payload is readable by anyone | Opaque session tokens + server-side |
| Mobile app ↔ API | ✅ Ideal use case | JWT + refresh tokens |
| SPA ↔ API (same origin) | ⚠️ Works but session cookies are simpler and more secure | Session cookies |
| Microservices | ✅ Ideal — RS256 signed by auth service | JWT with RS256 |
Auth0 (now part of Okta) is the most popular JWT-based identity platform. Their design:
/.well-known/jwks.json). Your API never sees the private key.app_metadata, permissions, roles to the JWT payload. Your API reads claims directly — no DB lookup needed.| Mistake | Why it's wrong | What to do instead |
|---|---|---|
| Hardcoding the JWT secret | Secret in source code → secret in Git → secret in every developer's laptop | , secrets manager, or key management service |
Accepting alg: none | Attacker creates a token with alg: "none" and empty signature. Many libraries accept it by default. | Explicitly set algorithms: ['HS256'] (or RS256) in jwt.verify() |
| Storing JWT in localStorage | Any on the page (including third-party scripts, XSS payloads) can read localStorage | Store in an HttpOnly cookie (not accessible to JS). For SPAs, use a BFF (Backend For Frontend) pattern. |
No expiration (exp) | Token is valid forever. If stolen, attacker has permanent access. | Always set expiresIn. 15 minutes for access tokens, 7 days for refresh tokens. |
| Long-lived access tokens | "I'll set it to 30 days so users don't have to log in" — if stolen, 30 days of access | Short-lived access tokens (15 min) + refresh tokens (7 days) + rotation |
| No refresh token rotation | A stolen refresh token gives the attacker unlimited new access tokens forever | Rotate refresh tokens on every use. Invalidate old token. Detect token reuse → revoke all. |
| Putting secrets in JWT payload | JWT payload is base64-encoded, NOT encrypted. Anyone can decode and read it. | Never put passwords, API keys, or PII in JWT claims. The payload is public. |
/.well-known/jwks.json. Rotate keys every 90 days. Old keys stay published until all tokens signed with them have expired.iss (issuer), aud (audience), exp, nbf, iat. A token from a different issuer or for a different audience should be rejected.alg: none. Always specify accepted algorithms: algorithms: ['HS256'].iss and aud claims. Prevents token reuse across different applications./refresh endpoint accepts a refresh token and returns new tokens. Implement refresh token rotation.openssl. Sign JWTs with the private key. Verify with the public key. Test that a token signed with the wrong key is rejected.What are the three parts of a JWT? Header (algorithm + type), Payload (claims — user ID, role, expiry), Signature (cryptographic proof the token hasn't been tampered with).
Why shouldn't you store a JWT in localStorage? Any JavaScript on the page can read localStorage — including malicious code from XSS attacks, third-party scripts, or browser extensions. Use HttpOnly cookies instead.
What is the benefit of short-lived access tokens? If a token is stolen, the attacker has a limited window (15 minutes) to use it. With refresh tokens, the legitimate user gets new access tokens seamlessly.
Explain the refresh token rotation pattern and why it's important. Every time a refresh token is used, the server issues a NEW refresh token and invalidates the old one. If an attacker steals a refresh token and uses it, the legitimate user's next refresh attempt fails (their token was invalidated). The server detects this reuse and revokes all tokens for that user. Without rotation, a stolen refresh token gives permanent access until it expires.
When would you use RS256 over HS256? In microservice architectures where an auth service signs tokens and multiple API services verify them. RS256 lets API services verify tokens with only a public key — they can't create new tokens. HS256 requires sharing the secret with every service, which means compromising any service compromises the entire auth system.
How do you revoke a JWT? Short-lived access tokens (15 min) + revocable refresh tokens is the primary strategy. For immediate revocation: maintain a blocklist (Redis) of revoked JWT IDs (jti) with TTL matching the token's remaining life. Check the blocklist on every request. Alternatively, include a token_version claim in the JWT and increment the user's version on logout — the JWT becomes invalid because the version doesn't match.
JWT is a stateless token format — the server can verify it without a database query. Use short-lived access tokens (15 min) + refresh tokens (7 days) with rotation. Sign with RS256 for microservices, HS256 for monoliths. Never store JWTs in localStorage. Never accept alg: none. Always validate iss, aud, and exp claims. Revocation is possible via short expiry + blocklist or token versioning. JWTs are not encrypted — the payload is readable by anyone with the token.
iss, aud, exp, nbf. Never accept alg: none.alg: none do and why is it dangerous? It tells the library to skip signature verification entirely. An attacker can create a valid-looking token with any payload. Always whitelist specific algorithms.