JWT Security Best Practices — Common Vulnerabilities and How to Avoid Them
JWT vulnerabilities include algorithm confusion attacks, weak secrets, missing expiry, and storing tokens in localStorage. Learn how to implement JWTs securely: algorithm...
JWT security requires knowing the attacks — the ‘none’ algorithm exploit, algorithm confusion between HS256 and RS256, weak secrets, and improper token storage are the most common vulnerabilities.
Use the JWT Decoder to inspect JWT headers and payloads.
The ‘none’ algorithm attack
Some early JWT libraries allowed alg: none, which meant no signature:
// Attacker creates a token with alg: none and admin claim:
// Header: {"alg":"none","typ":"JWT"}
// Payload: {"sub":"123","role":"admin","exp":9999999999}
// Signature: (empty)
// VULNERABLE: accept 'none' algorithm:
jwt.verify(token, secret, { algorithms: ['HS256', 'none'] }) // BAD!
// SECURE: whitelist only specific algorithms:
jwt.verify(token, secret, { algorithms: ['HS256'] }) // Good
Fix: Always specify allowed algorithms explicitly.
Algorithm confusion (HS256 vs RS256)
An RS256 JWT is signed with an RSA private key and verified with the public key. If a library uses the public key as the HMAC secret when it sees HS256, an attacker who knows the public key can forge tokens:
// VULNERABLE code pattern:
const secret = getKeyFromHeader(decodedHeader.alg); // Never do this!
// Attack: attacker takes the public RSA key (which is public!) and signs
// a fake token with HS256 using that public key as the HMAC secret
// SECURE: hard-code the expected algorithm:
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
// NOT: jwt.verify(token, keyLookup(header.alg))
Weak secrets
HMAC JWT security depends entirely on secret entropy:
// WEAK — short, predictable secrets:
const secret = 'secret'; // trivially crackable
const secret = 'password123'; // dictionary attack
const secret = 'jwt-secret'; // common string
// SECURE — random, 256+ bits:
import crypto from 'crypto';
const secret = crypto.randomBytes(32).toString('base64url');
// Example: 'Yx8kJ2mNpQrTsUvWx9yZaB3cD4eF5gH6iJ7kL8mN9o'
// Store in environment variable:
process.env.JWT_SECRET = secret
JWT crackers like jwt-cracker and hashcat can brute-force weak secrets offline.
Missing or far-future expiry
// VULNERABLE: no expiry
jwt.sign({ userId: 123 }, secret);
// Token valid forever!
// VULNERABLE: very long expiry
jwt.sign({ userId: 123 }, secret, { expiresIn: '365d' });
// Token valid 1 year after compromise
// SECURE: short-lived access tokens
jwt.sign({ userId: 123 }, secret, { expiresIn: '15m' });
// + refresh token mechanism for staying logged in
Validate all claims
// VULNERABLE: only verifying signature
const decoded = jwt.verify(token, secret);
// Doesn't check issuer, audience, or custom claims
// SECURE: verify signature + all expected claims
const decoded = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'https://auth.yourapp.com',
audience: 'https://api.yourapp.com',
});
// Then verify app-specific claims:
if (!decoded.sub || !decoded.role) {
throw new Error('Missing required claims');
}
Token storage (localStorage vs httpOnly cookie)
// VULNERABLE: localStorage (XSS can steal it)
localStorage.setItem('jwt', token);
// SECURE option 1: httpOnly cookie (XSS-resistant, but needs CSRF protection)
res.cookie('accessToken', token, {
httpOnly: true, // JavaScript cannot read it
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000,
});
// SECURE option 2: memory only (lost on refresh, use with refresh token)
// Store in a module-level variable, not in browser storage
let accessToken = null;
CSRF protection with cookies
If using cookies, protect against Cross-Site Request Forgery:
// SameSite=Strict is the strongest CSRF protection:
res.cookie('jwt', token, { sameSite: 'strict', ... });
// SameSite=Lax allows top-level navigation (links from other sites):
res.cookie('jwt', token, { sameSite: 'lax', ... });
// Double-submit cookie pattern (for SameSite=None):
// Set a non-httpOnly random token in cookie + require it in request header
Key rotation
// Use key IDs (kid) in JWT header for rotation:
const currentKey = {
id: 'key-2026-01',
secret: process.env.JWT_SECRET_2026_01,
};
// Sign with current key:
const token = jwt.sign(payload, currentKey.secret, {
algorithm: 'HS256',
keyid: currentKey.id,
expiresIn: '15m',
});
// Verify: look up key by kid
const keyStore = {
'key-2026-01': process.env.JWT_SECRET_2026_01,
'key-2025-12': process.env.JWT_SECRET_2025_12, // Previous key (still valid during rotation)
};
function verifyToken(token) {
const decoded = jwt.decode(token, { complete: true });
const secret = keyStore[decoded.header.kid];
if (!secret) throw new Error('Unknown key');
return jwt.verify(token, secret, { algorithms: ['HS256'] });
}
Related tools
- JWT Decoder — inspect JWT header, payload, signature
- JWT Token Structure — base64url encoding, claim types
- JWT Authentication Flow — login, refresh, logout
Related posts
- Decoding a JWT Is Not the Same as Verifying It — Every JWT bug in production reduces to the same mistake: trusting a decoded toke…
- JWT Security Checklist for 2026 — Twelve checks every JWT implementation should pass before shipping. The actual c…
- JWT Authentication Flow — Login, Token Storage, Refresh Tokens — JWT authentication involves issuing tokens on login, sending them with requests,…
- HMAC Authentication — Signing API Requests with Secret Keys — HMAC (Hash-based Message Authentication Code) signs API requests with a shared s…
- JWT Token Structure — Header, Payload, and Signature Explained — A JWT has three Base64URL-encoded parts: header, payload, and signature, separat…
Related tool
Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.
Written by Mian Ali Khalid. Part of the Dev Productivity pillar.