JSON Web Tokens (JWTs) are the de facto standard for API authentication and authorization. However, their apparent simplicity hides numerous security pitfalls. This guide covers JWT security best practices, common vulnerabilities, and how to implement tokens correctly.
┌─────────────────────────────────────────────────────────────────┐
│ JWT STRUCTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Header Payload Signature │
│ ────── ─────── ───────── │
│ eyJhbGci... . eyJzdWIi... . SflKxwRJ... │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ { │ │ { │ │ HMACSHA256( │ │
│ │ "alg": │ │ "sub":"123",│ │ base64(header) + │ │
│ │ "RS256",│ │ "name":"Jo",│ │ "." + │ │
│ │ "typ": │ │ "exp":12345,│ │ base64(payload), │ │
│ │ "JWT" │ │ "roles":[] │ │ secret │ │
│ │ } │ │ } │ │ ) │ │
│ └──────────┘ └──────────────┘ └──────────────────────┘ │
│ │
│ Base64URL Base64URL Cryptographic │
│ Encoded Encoded Signature │
│ │
│ ⚠️ NOT ENCRYPTED - Anyone can read header and payload! │
│ │
└─────────────────────────────────────────────────────────────────┘
| Algorithm | Type | Key | Use Case | Security |
|---|
| HS256 | HMAC | Shared secret | Single service | Good (if secret is strong) |
| HS384 | HMAC | Shared secret | Single service | Better |
| HS512 | HMAC | Shared secret | Single service | Best HMAC |
| RS256 | RSA | Public/Private | Distributed systems | Good |
| RS384 | RSA | Public/Private | Distributed systems | Better |
| RS512 | RSA | Public/Private | Distributed systems | Best RSA |
| ES256 | ECDSA | Public/Private | Mobile, performance | Recommended |
| ES384 | ECDSA | Public/Private | High security | Better |
| ES512 | ECDSA | Public/Private | Highest security | Best ECDSA |
| none | None | None | ❌ NEVER USE | Vulnerable |
Single Backend Service?
│
├─► Yes ─► HS256/HS384/HS512
│ (Shared secret, simpler)
│
└─► No ─► Multiple services verify tokens?
│
├─► Yes ─► RS256 or ES256
│ (Asymmetric, public key distribution)
│
└─► Performance critical?
│
├─► Yes ─► ES256 (smaller signatures)
│
└─► No ─► RS256 (widest compatibility)
{
"iss": "https://auth.example.com",
"sub": "user_12345",
"aud": "https://api.example.com",
"exp": 1735689600,
"nbf": 1735686000,
"iat": 1735686000,
"jti": "unique-token-id-abc123",
"roles": ["user", "admin"],
"permissions": ["read:data", "write:data"],
"tenant_id": "tenant_abc",
"email": "user@example.com"
}
const jwt = require('jsonwebtoken');
function validateToken(token) {
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
clockTolerance: 30,
});
if (!decoded.sub) {
throw new Error('Missing subject claim');
}
return decoded;
} catch (error) {
if (error.name === 'TokenExpiredError') {
throw new Error('Token expired');
}
if (error.name === 'JsonWebTokenError') {
throw new Error('Invalid token');
}
throw error;
}
}
const decoded = jwt.verify(token, secret);
jwt.verify(token, secret, {});
jwt.verify(token, secret, { algorithms: ['RS256', 'none'] });
jwt.verify(token, secret, { algorithms: ['RS256'] });
localStorage.setItem('token', accessToken);
sessionStorage.setItem('token', accessToken);
res.cookie('access_token', token, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 900000,
path: '/api'
});
class TokenStore {
#accessToken = null;
setToken(token) {
this.#accessToken = token;
}
getToken() {
return this.#accessToken;
}
clear() {
this.#accessToken = null;
}
}
┌─────────────┐ Session ┌─────────────┐ JWT ┌─────────────┐
│ Browser │◄───────────────────│ BFF │◄───────────────►│ API │
│ (SPA) │ Cookie (ID) │ (Backend) │ Bearer │ Services │
└─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ Token │
│ Store │
└─────────────┘
Benefits:
• Tokens never reach browser (no XSS risk)
• Session cookies are HttpOnly
• BFF handles token refresh
• Can implement additional security layers
ATTACK SCENARIO:
┌─────────────────────────────────────────────────────────────────┐
│ │
│ 1. Server expects RS256 (asymmetric) │
│ Public key: MIIBIjANBg... │
│ │
│ 2. Attacker changes header to HS256 (symmetric) │
│ {"alg":"HS256","typ":"JWT"} │
│ │
│ 3. Attacker signs with PUBLIC KEY as HMAC secret │
│ HMACSHA256(header.payload, publicKey) │
│ │
│ 4. Vulnerable server: │
│ - Reads alg: "HS256" from header │
│ - Uses stored key for HMAC verification │
│ - Verification succeeds! (same key used both sides) │
│ │
│ PREVENTION: NEVER trust the alg header! │
│ Always specify expected algorithm explicitly. │
│ │
└─────────────────────────────────────────────────────────────────┘
| Vector | Risk | Mitigation |
|---|
| URL query strings | Logged in server logs, browser history | Never send tokens in URLs |
| Referrer headers | Token leaked to external sites | Use Referrer-Policy: no-referrer |
| Browser storage | XSS can steal tokens | Use HttpOnly cookies |
| Error messages | Token exposed in stack traces | Never log full tokens |
| Client-side code | Visible in source/DevTools | Keep tokens in memory |
const secret = 'secret123';
const secret = 'password';
const secret = 'jwt-secret';
const secret = process.env.APP_NAME + '-jwt';
const secret = process.env.JWT_SECRET;
┌─────────────────────────────────────────────────────────────────┐
│ TOKEN LIFETIME STRATEGY │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Access Token: 5-15 minutes │
│ ├─► Sent with every API request │
│ ├─► Short-lived limits breach impact │
│ └─► Refresh when expired or about to expire │
│ │
│ Refresh Token: 7-30 days │
│ ├─► Used only to get new access tokens │
│ ├─► Stored more securely than access tokens │
│ ├─► Rotated on each use (one-time use) │
│ └─► Revocable server-side │
│ │
│ ID Token (OIDC): Same as access token │
│ ├─► Contains user identity claims │
│ ├─► Validated once at login │
│ └─► Not sent with API requests │
│ │
│ Timeline Example: │
│ ───────────────────────────────────────────────────────────── │
│ 0min 15min 30min 45min ... 7days │
│ │ │ │ │ │ │
│ │ AT1 │ AT2 │ AT3 │ AT4 │ │
│ └────────┴───────┴───────┴────────────┘ │
│ └──────────── Refresh Token ──────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
const redis = require('redis');
const client = redis.createClient();
async function revokeToken(jti, exp) {
const ttl = exp - Math.floor(Date.now() / 1000);
if (ttl > 0) {
await client.setEx(`blacklist:${jti}`, ttl, '1');
}
}
async function isRevoked(jti) {
const result = await client.get(`blacklist:${jti}`);
return result === '1';
}
async function validateToken(req, res, next) {
const decoded = jwt.verify(req.token, secret);
if (await isRevoked(decoded.jti)) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user = decoded;
next();
}
const user = {
id: '12345',
email: 'user@example.com',
tokenVersion: 3
};
const token = jwt.sign({
sub: user.id,
tokenVersion: user.tokenVersion
}, secret);
async function validateToken(decoded) {
const user = await getUser(decoded.sub);
if (decoded.tokenVersion !== user.tokenVersion) {
throw new Error('Token version mismatch - please re-authenticate');
}
}
async function revokeAllTokens(userId) {
await db.query(
'UPDATE users SET token_version = token_version + 1 WHERE id = $1',
[userId]
);
}
- JWT Decoder - Inspect token structure (never use with production tokens online)
- jwt.io - Debugger for development tokens
- jose - Comprehensive JWT library for Node.js
- PyJWT - JWT library for Python