JWT Security Best Practices: Algorithm Choice, Expiry, Rotation, and Attack Prevention
JSON Web Tokens are everywhere — but most implementations have critical security flaws. This guide covers algorithm selection (why RS256 beats HS256 in production), token expiry and rotation strategies, refresh token patterns, and how to defend against the top JWT attacks including algorithm confusion, the "none" algorithm bypass, and secret key brute-forcing.
What Is a JWT and How Does It Work?
A JWT is a Base64URL-encoded string with three dot-separated parts: header (algorithm + type), payload (claims), and signature. The server signs the token with a secret or private key; clients send it with every request, and the server verifies the signature to confirm authenticity without a database lookup.
Algorithm Selection: HS256 vs RS256 vs ES256
The most common JWT security mistake is using HS256 (HMAC-SHA256) in a distributed system. HS256 requires every service that verifies tokens to share the same secret — if one service is compromised, all tokens are forged. RS256 and ES256 use asymmetric keys: only the auth server holds the private signing key; other services only need the public key to verify.
// Anatomy of a JWT — Header.Payload.Signature
// 1. Header (Base64URL decoded)
const header = {
alg: "RS256", // Algorithm: RS256 (asymmetric) preferred over HS256 (symmetric)
typ: "JWT"
};
// 2. Payload (Base64URL decoded) — the claims
const payload = {
iss: "https://auth.example.com", // Issuer
sub: "user_01HXYZ123", // Subject (user ID)
aud: "https://api.example.com", // Audience (target service)
exp: 1735689600, // Expiry: Unix timestamp (15 min from now)
iat: 1735688700, // Issued at
jti: "8f14e0f9-21af-4c1e-8f26-3b4a5c6d7e8f", // Unique token ID (for revocation)
roles: ["user"], // Custom claims
email: "alice@example.com"
};
// 3. Signature (server-side only)
// RS256: sign(base64url(header) + '.' + base64url(payload), PRIVATE_KEY)
// Verification: verify(token, PUBLIC_KEY) — other services only need the public key
// --- Generating a JWT with jsonwebtoken (Node.js) ---
import jwt from 'jsonwebtoken';
import { readFileSync } from 'fs';
import { randomUUID } from 'crypto';
const privateKey = readFileSync('./private.pem'); // RSA private key
const publicKey = readFileSync('./public.pem'); // RSA public key (shared with all services)
function issueAccessToken(userId: string, roles: string[]): string {
return jwt.sign(
{
sub: userId,
aud: 'https://api.example.com',
roles,
jti: randomUUID(),
},
privateKey,
{
algorithm: 'RS256',
issuer: 'https://auth.example.com',
expiresIn: '15m', // Short-lived access token
}
);
}
function verifyAccessToken(token: string) {
return jwt.verify(token, publicKey, {
algorithms: ['RS256'], // CRITICAL: whitelist algorithms — never allow 'none'
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
}Token Expiry and Rotation Strategy
Short-lived access tokens (15 minutes) paired with long-lived refresh tokens (7–30 days) is the industry standard. When the access token expires, the client silently exchanges the refresh token for a new pair. Refresh tokens must be stored server-side (in a database) so they can be revoked — unlike access tokens which are stateless.
// Refresh Token Pattern — Stateless Access + Stateful Refresh
import jwt from 'jsonwebtoken';
import { randomBytes } from 'crypto';
// ---- Token issuance ----
async function issueTokenPair(userId: string) {
const accessToken = jwt.sign(
{ sub: userId, jti: randomUUID() },
process.env.JWT_PRIVATE_KEY!,
{ algorithm: 'RS256', expiresIn: '15m' }
);
// Refresh token: random bytes stored in DB — NOT a JWT
const refreshToken = randomBytes(64).toString('hex');
const refreshExpiry = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // 30 days
// Store hashed refresh token in DB (never store plaintext)
await db.refreshTokens.create({
tokenHash: sha256(refreshToken),
userId,
expiresAt: refreshExpiry,
createdAt: new Date(),
});
return { accessToken, refreshToken, refreshExpiry };
}
// ---- Token refresh endpoint ----
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.cookies; // httpOnly cookie
if (!refreshToken) return res.status(401).json({ error: 'No refresh token' });
const stored = await db.refreshTokens.findOne({
tokenHash: sha256(refreshToken),
expiresAt: { >: new Date() }, // Not expired
revokedAt: null, // Not revoked
});
if (!stored) return res.status(401).json({ error: 'Invalid or expired refresh token' });
// Rotate: invalidate old, issue new pair (refresh token rotation)
await db.refreshTokens.update(
{ id: stored.id },
{ revokedAt: new Date() } // Revoke old refresh token
);
const newPair = await issueTokenPair(stored.userId);
// Set new refresh token as httpOnly cookie
res.cookie('refreshToken', newPair.refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
expires: newPair.refreshExpiry,
path: '/auth/refresh', // Scope cookie to refresh endpoint only
});
return res.json({ accessToken: newPair.accessToken });
});
// ---- Logout: revoke refresh token ----
app.post('/auth/logout', async (req, res) => {
const { refreshToken } = req.cookies;
if (refreshToken) {
await db.refreshTokens.update(
{ tokenHash: sha256(refreshToken) },
{ revokedAt: new Date() }
);
}
res.clearCookie('refreshToken', { path: '/auth/refresh' });
return res.json({ success: true });
});Common JWT Attacks and Defenses
The most dangerous JWT vulnerabilities are: (1) Algorithm confusion — attacker changes alg to "none" to bypass signature verification; (2) RS256→HS256 confusion — attacker tricks a server expecting RS256 into using the public key as an HS256 secret; (3) Weak secrets — HS256 keys under 256 bits are brute-forceable; (4) Missing exp claim validation — libraries may not validate expiry by default.
// JWT Attack Defenses — Code Patterns
// ---- ATTACK 1: Algorithm confusion ("none" bypass) ----
// VULNERABLE: never use verify() without specifying allowed algorithms
const VULNERABLE = jwt.verify(token, secret); // attacker can set alg: "none"
// SAFE: always whitelist algorithms
const SAFE = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Never include 'none', 'HS256' if you're using RS256
});
// ---- ATTACK 2: RS256 > HS256 confusion ----
// Attacker changes alg from RS256 to HS256 and signs with the PUBLIC key as the secret
// Defense: always specify algorithm on verify — never infer from the token header
// VULNERABLE:
const header = JSON.parse(atob(token.split('.')[0]));
jwt.verify(token, publicKey, { algorithms: [header.alg] }); // attacker controls this!
// SAFE:
jwt.verify(token, publicKey, { algorithms: ['RS256'] }); // hardcoded
// ---- ATTACK 3: Weak HS256 secrets (brute-force) ----
// VULNERABLE: short or predictable secrets
const BAD_SECRET = 'secret';
const BAD_SECRET2 = 'mysupersecretkey';
// SAFE: 256-bit cryptographically random secret
import { randomBytes } from 'crypto';
const GOOD_SECRET = randomBytes(32).toString('hex'); // 256-bit
// Store in environment variable, rotate annually
// ---- ATTACK 4: Missing expiry validation ----
// Some older libraries skip exp check by default
// SAFE: always validate exp (jsonwebtoken does this by default)
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
clockTolerance: 30, // Allow 30 seconds clock skew max
ignoreExpiration: false, // NEVER set this to true in production
});
} catch (err) {
if (err instanceof jwt.TokenExpiredError) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid token' });
}
// ---- Token blacklist (for early revocation via jti) ----
import { createClient } from 'redis';
const redis = createClient();
async function revokeToken(jti: string, exp: number) {
const ttl = exp - Math.floor(Date.now() / 1000); // seconds until natural expiry
if (ttl > 0) {
await redis.set(`jwt:revoked:${jti}`, '1', { EX: ttl });
}
}
async function isTokenRevoked(jti: string): Promise<boolean> {
return (await redis.get(`jwt:revoked:${jti}`)) !== null;
}
// Middleware: check blacklist
app.use(async (req, res, next) => {
const decoded = jwt.verify(req.headers.authorization?.split(' ')[1] ?? '', publicKey, {
algorithms: ['RS256'],
});
if (await isTokenRevoked((decoded as jwt.JwtPayload).jti ?? '')) {
return res.status(401).json({ error: 'Token revoked' });
}
next();
});Secure Token Storage
Access tokens: store in memory (JavaScript variable) — not localStorage (XSS-vulnerable) and not cookies without httpOnly. Refresh tokens: store in httpOnly, Secure, SameSite=Strict cookies. This combination prevents XSS from reading the refresh token and CSRF from using it (SameSite blocks cross-origin cookie submission).
Frequently Asked Questions
Should I use JWT or opaque session tokens?
Use opaque session tokens (random IDs stored in a database) for traditional server-rendered apps — they are simpler, instantly revocable, and have no algorithm attack surface. Use JWTs for microservices or APIs where stateless verification across multiple services is needed. Do not use JWTs just because they are popular — the added complexity requires careful security implementation.
How do I revoke a JWT before it expires?
JWTs are stateless by design, making instant revocation difficult. Solutions: (1) Short expiry (15 min) — compromised tokens expire quickly; (2) Token blacklist — store revoked JTI (JWT ID) claims in Redis until their exp time; (3) Token version in user record — increment a version field on revocation, include version in JWT, reject tokens with outdated versions. Option 3 requires one database lookup per request but provides instant revocation.
What claims should every JWT contain?
Required: iss (issuer — your auth server URL), sub (subject — user ID), aud (audience — intended service), exp (expiration — Unix timestamp), iat (issued at), jti (unique token ID for revocation). Optional but useful: nbf (not before), roles/permissions claims. Always validate iss and aud on the receiving service to prevent token reuse across services.
How long should JWT secrets and private keys be?
HS256 secrets: minimum 256 bits (32 bytes) of cryptographically random data — use crypto.randomBytes(32). HS512: minimum 512 bits. RS256/RS512: minimum 2048-bit RSA key (4096-bit recommended for long-lived keys). ES256: 256-bit elliptic curve key (P-256). Never use passwords or human-readable strings as secrets. Rotate secrets annually or after any suspected compromise.