- Published on
JWT Security Pitfalls — Algorithm Confusion, Key Rotation, and Token Theft Prevention
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
JWT tokens have become the de facto standard for stateless authentication in modern APIs, but their flexibility creates security pitfalls that developers regularly fall into. Algorithm confusion attacks that silently drop from exploitation reports, stolen refresh tokens circulating through compromised devices, and keys rotated without proper versioning—these aren't hypothetical threats, they're recurring incidents in production systems.
This post covers the JWT vulnerabilities that matter most: algorithm confusion (including the alg: none bypass), safe key rotation with JWKS endpoints, detecting token theft through refresh token family invalidation, and hardening token lifecycle with binding and revocation.
- Algorithm Confusion Attacks
- JWKS Endpoint for Seamless Key Rotation
- Short-Lived Access Tokens + Refresh Token Rotation
- Token Binding and Revocation
- Checklist
- Conclusion
Algorithm Confusion Attacks
The most insidious JWT vulnerability is algorithm confusion. Your API might expect RS256 (RSA asymmetric), but if it doesn't explicitly validate the algorithm claim, an attacker can forge a token using HS256 (HMAC symmetric), signing it with your public key as the secret.
Here's vulnerable code:
import jwt from 'jsonwebtoken';
// VULNERABLE: Accepts any algorithm
const verifyVulnerable = (token: string, publicKey: string) => {
try {
const decoded = jwt.verify(token, publicKey);
return decoded;
} catch (err) {
throw new Error('Invalid token');
}
};
// Attack: Attacker signs with HS256 using publicKey as secret
const forgedToken = jwt.sign(
{ sub: 'admin', role: 'admin' },
publicKey,
{ algorithm: 'HS256' }
);
The fix requires explicit algorithm validation:
import jwt from 'jsonwebtoken';
interface TokenPayload {
sub: string;
role: string;
iat: number;
}
// SECURE: Validates expected algorithm
const verifySecure = (token: string, publicKey: string): TokenPayload => {
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // Only RS256 allowed
issuer: 'https://auth.example.com',
audience: 'api.example.com',
});
return decoded as TokenPayload;
} catch (err) {
if (err instanceof jwt.JsonWebTokenError) {
throw new Error(`JWT verification failed: ${err.message}`);
}
throw err;
}
};
// Also validate alg claim explicitly
const verifyWithAlgCheck = (
token: string,
publicKey: string
): TokenPayload => {
const decoded = jwt.decode(token, { complete: true });
if (!decoded || decoded.header.alg !== 'RS256') {
throw new Error('Invalid JWT algorithm');
}
return verifySecure(token, publicKey);
};
The alg: none vulnerability is equally dangerous—some libraries accept unsigned tokens if alg is set to "none":
// NEVER allow this
const vulnerableNone = jwt.sign(
{ sub: 'admin' },
'',
{ algorithm: 'none' }
);
// Fix: Reject in middleware
const validateAlgorithm = (token: string) => {
const decoded = jwt.decode(token, { complete: true });
if (decoded?.header?.alg === 'none') {
throw new Error('Algorithm "none" is not permitted');
}
};
JWKS Endpoint for Seamless Key Rotation
Key rotation is essential, but rolling keys while validating tokens becomes complex. A JWKS (JSON Web Key Set) endpoint lets you rotate keys without downtime.
Here's a production-ready implementation:
import express from 'express';
import jwt from 'jsonwebtoken';
import NodeRSA from 'node-rsa';
import { promisify } from 'util';
interface JWKSKey {
kty: string;
use: string;
kid: string;
n: string;
e: string;
}
interface JWKSResponse {
keys: JWKSKey[];
}
const key = new NodeRSA({ b: 2048 });
const publicKey = key.exportKey('pkcs8-public-pem');
const privateKey = key.exportKey('pkcs8-private-pem');
const generateKid = (index: number): string => {
return `key-${index}-${Date.now()}`;
};
class KeyRotationManager {
private currentKeyId: string;
private keys: Map<string, string> = new Map();
private keyHistory: string[] = [];
constructor() {
this.currentKeyId = generateKid(0);
this.keys.set(this.currentKeyId, publicKey);
}
rotateKey(): string {
const newKeyId = generateKid(this.keyHistory.length + 1);
const newKey = new NodeRSA({ b: 2048 });
const newPublicKey = newKey.exportKey('pkcs8-public-pem');
this.keys.set(newKeyId, newPublicKey);
this.keyHistory.push(this.currentKeyId);
// Keep last 3 key versions for token validation grace period
if (this.keyHistory.length > 3) {
const oldestKey = this.keyHistory.shift();
if (oldestKey) this.keys.delete(oldestKey);
}
this.currentKeyId = newKeyId;
return newKeyId;
}
getPublicKey(kid: string): string | undefined {
return this.keys.get(kid);
}
getCurrentKeyId(): string {
return this.currentKeyId;
}
toJWKS(): JWKSResponse {
const keys: JWKSKey[] = [];
this.keys.forEach((pubKey, kid) => {
// Parse RSA key to JWK format
const rsaKey = new NodeRSA(pubKey);
const exported = rsaKey.exportKey('components-public');
keys.push({
kty: 'RSA',
use: 'sig',
kid,
n: Buffer.from(exported.n).toString('base64'),
e: Buffer.from(exported.e).toString('base64'),
});
});
return { keys };
}
}
const keyManager = new KeyRotationManager();
const app = express();
app.get('/.well-known/jwks.json', (req, res) => {
res.json(keyManager.toJWKS());
});
app.post('/rotate-key', (req, res) => {
const newKid = keyManager.rotateKey();
res.json({ message: 'Key rotated', kid: newKid });
});
Short-Lived Access Tokens + Refresh Token Rotation
Limit access token lifetime to 15 minutes, use refresh tokens for long-term sessions, and rotate refresh tokens on each use:
interface TokenPair {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
interface RefreshTokenRecord {
tokenId: string;
userId: string;
familyId: string;
rotation: number;
issuedAt: number;
expiresAt: number;
}
class TokenService {
private refreshTokens: Map<string, RefreshTokenRecord> = new Map();
private revokedFamilies: Set<string> = new Set();
issueTokenPair(userId: string): TokenPair {
const familyId = `family-${userId}-${Date.now()}`;
const tokenId = `token-${Math.random().toString(36).substr(2, 9)}`;
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m', keyid: keyManager.getCurrentKeyId() }
);
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh', tokenId, familyId, rotation: 0 },
privateKey,
{ algorithm: 'RS256', expiresIn: '7d', keyid: keyManager.getCurrentKeyId() }
);
this.refreshTokens.set(tokenId, {
tokenId,
userId,
familyId,
rotation: 0,
issuedAt: Date.now(),
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
});
return { accessToken, refreshToken, expiresIn: 900 };
}
rotateRefreshToken(oldRefreshToken: string): TokenPair | null {
try {
const decoded = jwt.verify(oldRefreshToken, publicKey, {
algorithms: ['RS256'],
}) as any;
const record = this.refreshTokens.get(decoded.tokenId);
if (!record) return null;
// Theft detection: same token used twice
if (record.rotation > decoded.rotation) {
this.revokedFamilies.add(record.familyId);
return null;
}
// Invalidate old token
this.refreshTokens.delete(decoded.tokenId);
// Issue new token with incremented rotation
const newTokenId = `token-${Math.random().toString(36).substr(2, 9)}`;
const newRefreshToken = jwt.sign(
{
sub: decoded.sub,
type: 'refresh',
tokenId: newTokenId,
familyId: decoded.familyId,
rotation: decoded.rotation + 1,
},
privateKey,
{ algorithm: 'RS256', expiresIn: '7d' }
);
const accessToken = jwt.sign(
{ sub: decoded.sub, type: 'access' },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
this.refreshTokens.set(newTokenId, {
tokenId: newTokenId,
userId: decoded.sub,
familyId: decoded.familyId,
rotation: decoded.rotation + 1,
issuedAt: Date.now(),
expiresAt: Date.now() + 7 * 24 * 60 * 60 * 1000,
});
return { accessToken, refreshToken: newRefreshToken, expiresIn: 900 };
} catch (err) {
return null;
}
}
isFamilyRevoked(familyId: string): boolean {
return this.revokedFamilies.has(familyId);
}
}
Token Binding and Revocation
Bind tokens to device fingerprint or IP to prevent theft of in-transit tokens:
interface RequestContext {
ip: string;
fingerprint: string;
}
const createTokenWithBinding = (
userId: string,
context: RequestContext
): string => {
return jwt.sign(
{
sub: userId,
type: 'access',
ip: context.ip,
fp: context.fingerprint,
},
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
};
const validateTokenBinding = (
token: string,
currentContext: RequestContext
): boolean => {
try {
const decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
}) as any;
// Strict binding
if (decoded.ip !== currentContext.ip) {
return false;
}
if (decoded.fp !== currentContext.fingerprint) {
return false;
}
return true;
} catch {
return false;
}
};
// Token revocation with Redis denylist
class TokenRevocationList {
private denylist: Set<string> = new Set();
revoke(jti: string): void {
this.denylist.add(jti);
// In production: setex in Redis with token expiration
}
isRevoked(jti: string): boolean {
return this.denylist.has(jti);
}
}
const createRevocableToken = (userId: string): string => {
const jti = `jti-${Math.random().toString(36).substr(2, 9)}`;
return jwt.sign(
{ sub: userId, jti },
privateKey,
{ algorithm: 'RS256', expiresIn: '15m' }
);
};
Checklist
- Explicitly validate JWT algorithm (RS256 only, never "none")
- Implement JWKS endpoint with multiple key versions for rotation grace period
- Set access token expiration to 15 minutes or less
- Rotate refresh tokens on each use and detect theft via family invalidation
- Use httpOnly cookies with secure flag for refresh tokens in browsers
- Implement token binding to IP or device fingerprint
- Maintain a revocation denylist (Redis) for immediate token invalidation
- Test algorithm confusion with forged HS256 tokens
- Log all token validation failures for threat detection
Conclusion
JWT security is about defense in depth: preventing algorithm confusion through explicit validation, rotating keys seamlessly with JWKS, keeping access tokens short-lived, and detecting theft with refresh token family invalidation. Combine these with token binding and revocation, and your authentication becomes resilient to the most common JWT exploits in production systems.