Loading content...
JWTs are deceptively simple. A three-part string, some Base64 encoding, a signature—what could go wrong? As it turns out, everything.
JWT-related vulnerabilities have been discovered in countless applications, from startups to major enterprises. The Auth0 library had algorithm confusion bugs. AWS Cognito had timing attack vulnerabilities. Countless applications have leaked tokens through logs, stored them insecurely, or failed to validate critical claims.
This page is your security checklist. We'll cover every major vulnerability class, demonstrate how attacks work, and provide concrete defenses. By the end, you'll understand not just how JWTs work, but how they fail—and how to prevent those failures in your systems.
By the end of this page, you will understand the major JWT vulnerability classes (algorithm confusion, key injection, token theft), implement secure token storage on both client and server, apply defense-in-depth principles, and build JWT implementations that withstand real-world attacks.
The most infamous JWT vulnerability is algorithm confusion (also called algorithm substitution). This attack exploits the fact that different algorithms use keys in fundamentally different ways.
The Attack Scenario:
alg: HS2561234567891011121314151617181920212223242526272829303132
// HOW THE ATTACK WORKS (DO NOT DO THIS IN PRODUCTION) // Server's legitimate setupconst publicKey = fs.readFileSync('public.pem'); // For verifying RS256const privateKey = fs.readFileSync('private.pem'); // For signing RS256 // Legitimate token creationconst legitimateToken = jwt.sign( { sub: 'user-123', admin: false }, privateKey, { algorithm: 'RS256' }); // VULNERABLE verification (uses algorithm from token header)function vulnerableVerify(token: string) { const decoded = jwt.decode(token, { complete: true }); const algorithm = decoded.header.alg; // DANGER: Trusting token's claim // If algorithm is HS256, 'publicKey' is used as HMAC secret // If algorithm is RS256, 'publicKey' is used as RSA public key return jwt.verify(token, publicKey, { algorithms: [algorithm] });} // Attacker's forged tokenconst forgedToken = jwt.sign( { sub: 'user-123', admin: true }, // Escalated privileges! publicKey, // Using public key as HMAC secret { algorithm: 'HS256' } // Switching to symmetric algorithm); // Vulnerable server accepts the forged token!vulnerableVerify(forgedToken); // SUCCEEDS - attacker is now adminThe Fix: Explicit Algorithm Specification
Never let the token dictate which algorithm to use. Your server should have a fixed, expected algorithm.
1234567891011121314151617181920212223242526272829303132333435
// SECURE: Explicit algorithm specificationconst EXPECTED_ALGORITHM = 'RS256'; function secureVerify(token: string, publicKey: Buffer): JwtPayload { // Explicitly specify the ONLY algorithm we accept return jwt.verify(token, publicKey, { algorithms: [EXPECTED_ALGORITHM], // Rejects anything else }) as JwtPayload;} // Even better: Use different keys for different algorithms// This makes algorithm confusion structurally impossible interface KeyProvider { getRS256Key(): Buffer; // Public key for RS256 getHS256Key(): Buffer; // Secret for HS256 (not the public key!)} function superSecureVerify( token: string, keyProvider: KeyProvider): JwtPayload { // Decode header first (without verification) const decoded = jwt.decode(token, { complete: true }); // Select key based on OUR configuration, not the token const algorithm = EXPECTED_ALGORITHM; const key = algorithm.startsWith('RS') ? keyProvider.getRS256Key() : keyProvider.getHS256Key(); return jwt.verify(token, key, { algorithms: [algorithm], }) as JwtPayload;}CVE-2015-9235 affected the jwt-go library. CVE-2016-10555 affected the node-jose library. Multiple major JWT libraries have had this vulnerability. Always check your library's documentation for proper usage and keep dependencies updated.
The JWT specification (RFC 7519) allows an "alg": "none" value, indicating an unsigned token. This is meant for contexts where the token is transmitted through a secure channel and doesn't need a signature.
In practice, this is almost never appropriate for web applications, and supporting it opens a serious vulnerability.
123456789101112131415161718192021222324252627282930313233
// UNSIGNED TOKEN ATTACK // Attacker constructs a token with alg: noneconst header = { alg: 'none', typ: 'JWT' };const payload = { sub: 'admin', role: 'super_admin' }; // Base64URL encodeconst encodedHeader = Buffer.from(JSON.stringify(header)).toString('base64url');const encodedPayload = Buffer.from(JSON.stringify(payload)).toString('base64url'); // No signature (or empty signature)const forgedToken = `${encodedHeader}.${encodedPayload}.`; // Vulnerable libraries may accept this!// jwt.verify(forgedToken, secret, { algorithms: ['HS256', 'none'] }) // BAD! // PREVENTION: Never include 'none' in allowed algorithmsjwt.verify(token, secret, { algorithms: ['HS256'], // Explicitly list ONLY real algorithms}); // Or explicitly reject 'none'function rejectNoneAlgorithm(token: string): void { const decoded = jwt.decode(token, { complete: true }); if (!decoded || !decoded.header) { throw new Error('Invalid token format'); } if (decoded.header.alg.toLowerCase() === 'none') { throw new Error('Algorithm "none" is not permitted'); }}Attackers may try variations: 'NONE', 'None', 'nOnE'. Ensure your rejection is case-insensitive. Some libraries have been vulnerable to case-sensitivity issues.
Several JWT header parameters (jku, x5u, jwk, x5c) specify URLs or embedded keys that should be used for verification. If a server trusts these headers without validation, an attacker can inject their own key.
Headers That Can Be Exploited:
| Header | Purpose | Attack Vector |
|---|---|---|
| jku | URL to JWK Set | Point to attacker-controlled URL with attacker's keys |
| x5u | URL to X.509 certificate | Point to attacker-controlled certificate |
| jwk | Embedded JSON Web Key | Embed attacker's public key directly in token |
| x5c | Embedded X.509 certificate chain | Embed attacker's certificate chain in token |
123456789101112131415161718192021222324252627282930313233343536373839404142
// JKU INJECTION ATTACK EXAMPLE // Attacker creates a key pairconst attackerKeyPair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048,}); // Attacker hosts their public key at an attacker-controlled URL// https://evil.com/.well-known/jwks.json// {// "keys": [{// "kty": "RSA",// "kid": "attacker-key",// "n": "...",// "e": "AQAB"// }]// } // Attacker forges token pointing to their keysconst forgedToken = jwt.sign( { sub: 'admin', role: 'super_admin' }, attackerKeyPair.privateKey, { algorithm: 'RS256', header: { jku: 'https://evil.com/.well-known/jwks.json', // Attacker's key URL kid: 'attacker-key', }, }); // VULNERABLE verification fetches key from jku headerasync function vulnerableVerify(token: string) { const decoded = jwt.decode(token, { complete: true }); const jku = decoded.header.jku; // DANGER: Trusting token's URL // Fetches attacker's key from attacker's server const keys = await fetch(jku).then(r => r.json()); const key = keys.keys.find(k => k.kid === decoded.header.kid); return jwt.verify(token, key); // Verifies against attacker's key!}Prevention Strategies:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// SECURE: Statically configured JWKS with key pinning interface SecureKeyProvider { // Cached keys from trusted JWKS endpoint private keyCache: Map<string, crypto.KeyObject> = new Map(); private readonly TRUSTED_JWKS_URL = 'https://auth.mycompany.com/.well-known/jwks.json'; private readonly TRUSTED_ISSUER = 'https://auth.mycompany.com'; async getKey(kid: string): Promise<crypto.KeyObject> { // Return from cache if available if (this.keyCache.has(kid)) { return this.keyCache.get(kid)!; } // Fetch from TRUSTED URL only (not from token) const keys = await this.fetchTrustedKeys(); const key = keys.find(k => k.kid === kid); if (!key) { throw new Error(`Unknown key ID: ${kid}`); } const cryptoKey = crypto.createPublicKey(key); this.keyCache.set(kid, cryptoKey); return cryptoKey; } private async fetchTrustedKeys(): Promise<JWK[]> { // ONLY fetch from trusted, hardcoded URL const response = await fetch(this.TRUSTED_JWKS_URL); if (!response.ok) { throw new Error('Failed to fetch JWKS'); } const data = await response.json(); return data.keys; }} // Verify using secure key providerasync function secureVerify(token: string): Promise<JwtPayload> { const decoded = jwt.decode(token, { complete: true }); // IGNORE jku, x5u, jwk, x5c from the token // Use kid only to select from OUR pre-trusted keys const kid = decoded?.header?.kid; if (!kid) { throw new Error('Token missing kid header'); } const key = await secureKeyProvider.getKey(kid); return jwt.verify(token, key, { algorithms: ['RS256'], issuer: 'https://auth.mycompany.com', // Verify issuer too }) as JwtPayload;}Where and how you store tokens directly impacts your vulnerability to theft. Different storage mechanisms have different security properties.
| Storage | XSS Vulnerable | CSRF Vulnerable | Persists | Best For |
|---|---|---|---|---|
| localStorage | Yes (critical) | No | Yes | Never for auth tokens |
| sessionStorage | Yes (critical) | No | Until tab close | Avoid for auth tokens |
| JavaScript variable | Yes (but harder) | No | No (lost on refresh) | Short-lived access tokens with refresh flow |
| httpOnly Cookie | No (not accessible to JS) | Yes (mitigatable) | Configurable | Refresh tokens, session tokens |
| IndexedDB | Yes (critical) | No | Yes | Never for auth tokens |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
// SERVER: Setting secure cookies app.post('/api/auth/login', async (req, res) => { const user = await authenticate(req.body); const tokens = await tokenService.createTokenPair(user); // Set refresh token as httpOnly cookie res.cookie('refresh_token', tokens.refreshToken, { httpOnly: true, // Inaccessible to JavaScript secure: true, // HTTPS only sameSite: 'strict', // CSRF protection path: '/api/auth', // Only sent to auth endpoints maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }); // Return access token in response body // Client stores this in memory only res.json({ accessToken: tokens.accessToken, expiresIn: 900, // 15 minutes });}); app.post('/api/auth/refresh', async (req, res) => { // Refresh token comes from httpOnly cookie automatically const refreshToken = req.cookies.refresh_token; if (!refreshToken) { return res.status(401).json({ error: 'No refresh token' }); } try { const tokens = await tokenService.rotateRefreshToken(refreshToken); // Set new refresh token cookie res.cookie('refresh_token', tokens.refreshToken, { httpOnly: true, secure: true, sameSite: 'strict', path: '/api/auth', maxAge: 7 * 24 * 60 * 60 * 1000, }); res.json({ accessToken: tokens.accessToken, expiresIn: 900, }); } catch (error) { // Clear invalid refresh token cookie res.clearCookie('refresh_token', { path: '/api/auth' }); return res.status(401).json({ error: 'Invalid refresh token' }); }});123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960
// CLIENT: Secure token handling class SecureTokenManager { // Store access token in memory ONLY // Lost on page refresh — that's intentional private accessToken: string | null = null; private tokenExpiresAt: number = 0; setAccessToken(token: string, expiresIn: number): void { this.accessToken = token; this.tokenExpiresAt = Date.now() + (expiresIn * 1000); } getAccessToken(): string | null { // Check if token is still valid if (!this.accessToken || Date.now() >= this.tokenExpiresAt) { return null; } return this.accessToken; } async ensureValidToken(): Promise<string> { const token = this.getAccessToken(); if (token) return token; // Refresh using httpOnly cookie (automatically included) const response = await fetch('/api/auth/refresh', { method: 'POST', credentials: 'include', // Include cookies }); if (!response.ok) { throw new SessionExpiredError(); } const data = await response.json(); this.setAccessToken(data.accessToken, data.expiresIn); return data.accessToken; } clear(): void { this.accessToken = null; this.tokenExpiresAt = 0; }} // Usage with fetch wrapperconst tokenManager = new SecureTokenManager(); async function authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> { const token = await tokenManager.ensureValidToken(); return fetch(url, { ...options, headers: { ...options.headers, 'Authorization': `Bearer ${token}`, }, });}With SameSite=Strict or SameSite=Lax, browsers don't send cookies on cross-origin requests. This largely mitigates CSRF attacks without requiring CSRF tokens. Use Strict for sensitive cookies when possible.
Beyond the specific JWT vulnerabilities, several general attack patterns target token-based authentication.
XSS is the most dangerous threat to client-side token storage. An attacker who can execute JavaScript in your context can steal any accessible tokens.
Defenses:
123456789101112131415161718
// Content Security Policy headerapp.use((req, res, next) => { res.setHeader('Content-Security-Policy', [ "default-src 'self'", "script-src 'self'", // No inline scripts, no external scripts "style-src 'self' 'unsafe-inline'", // Allow inline styles (CSS-in-JS) "img-src 'self' data: https:", "connect-src 'self' https://api.example.com", "frame-ancestors 'none'", // Prevent clickjacking "base-uri 'self'", "form-action 'self'", ].join('; ')); next();}); // Even with XSS, httpOnly refresh tokens can't be stolen directly// Attacker can only use them in-context (session riding)// Short access token expiration limits damageAttacker intercepts or predicts a token and uses it before the legitimate user.
Defenses:
1234567891011121314151617181920212223242526272829303132333435
// Token binding example - include client fingerprint in token function createBoundToken(user: User, clientInfo: ClientInfo): string { // Create fingerprint from client characteristics const fingerprint = crypto.createHash('sha256') .update(clientInfo.userAgent) .update(clientInfo.ipSubnet) // /24 for IPv4, /48 for IPv6 .digest('hex') .substring(0, 16); return jwt.sign({ sub: user.id, fingerprint, // Bound to this client iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 900, }, secret);} function validateBoundToken(token: string, clientInfo: ClientInfo): JwtPayload { const payload = jwt.verify(token, secret) as JwtPayload; // Verify fingerprint matches current client const expectedFingerprint = crypto.createHash('sha256') .update(clientInfo.userAgent) .update(clientInfo.ipSubnet) .digest('hex') .substring(0, 16); if (payload.fingerprint !== expectedFingerprint) { // Token used from different client - possible theft throw new TokenBindingMismatchError(); } return payload;}Attacker captures and reuses a valid token.
Defenses:
No single defense is sufficient. Combine multiple layers: httpOnly cookies + short expiration + CSP + HTTPS + token binding. An attacker who bypasses one layer should face another.
The security of JWT signatures depends entirely on the security of signing keys. Key compromise = complete authentication bypass.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Secure key loading from environment/secrets manager import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager'; class SecureKeyProvider { private jwtSecret: string | null = null; private rsaPrivateKey: crypto.KeyObject | null = null; private rsaPublicKey: crypto.KeyObject | null = null; async initialize(): Promise<void> { const secrets = new SecretsManagerClient({}); // Load from AWS Secrets Manager (or HashiCorp Vault, etc.) const response = await secrets.send(new GetSecretValueCommand({ SecretId: 'production/jwt/signing-keys', })); const keys = JSON.parse(response.SecretString!); // HMAC secret - should be at least 256 bits (32 bytes) this.jwtSecret = keys.hmacSecret; if (Buffer.from(this.jwtSecret).length < 32) { throw new Error('HMAC secret must be at least 256 bits'); } // RSA keys this.rsaPrivateKey = crypto.createPrivateKey(keys.rsaPrivateKey); this.rsaPublicKey = crypto.createPublicKey(keys.rsaPublicKey); } getHmacSecret(): string { if (!this.jwtSecret) { throw new Error('Keys not initialized'); } return this.jwtSecret; } getRsaPrivateKey(): crypto.KeyObject { if (!this.rsaPrivateKey) { throw new Error('Keys not initialized'); } return this.rsaPrivateKey; } getRsaPublicKey(): crypto.KeyObject { if (!this.rsaPublicKey) { throw new Error('Keys not initialized'); } return this.rsaPublicKey; }} // Strong secret generation (for development/initial setup)function generateStrongSecret(): string { // 256 bits = 32 bytes of cryptographically random data return crypto.randomBytes(32).toString('base64');} // DO NOT DO THIS:// const JWT_SECRET = 'mysecretkey'; // Hardcoded, predictable// const JWT_SECRET = process.env.JWT_SECRET || 'default'; // Fallback is dangerousInclude a kid (key ID) in your JWT headers and maintain multiple active keys. When rotating, add the new key, update token creation to use it, wait for old tokens to expire, then remove the old key. This enables zero-downtime key rotation.
Effective security requires visibility. You must be able to detect attacks, investigate incidents, and understand authentication patterns.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182
// Secure authentication event logging interface AuthEvent { timestamp: Date; eventType: 'login' | 'logout' | 'refresh' | 'revoke' | 'validation_failure'; userId?: string; tokenId?: string; // jti - safe to log clientIp: string; userAgent: string; success: boolean; failureReason?: string; metadata?: Record<string, unknown>;} class SecureAuthLogger { async log(event: AuthEvent): Promise<void> { // Sanitize before logging const sanitizedEvent = { ...event, // Truncate/hash potentially sensitive fields userAgent: event.userAgent.substring(0, 200), metadata: this.sanitizeMetadata(event.metadata), }; // Send to structured logging system await this.logger.info('auth_event', sanitizedEvent); // Send critical events to security team if (this.isSecurityRelevant(event)) { await this.alerter.send(sanitizedEvent); } } private sanitizeMetadata(metadata?: Record<string, unknown>): Record<string, unknown> { if (!metadata) return {}; // Remove sensitive keys const { token, password, secret, key, ...safe } = metadata as any; return safe; } private isSecurityRelevant(event: AuthEvent): boolean { // Alert on security-relevant events return ( event.eventType === 'revoke' || event.failureReason?.includes('reuse') || event.failureReason?.includes('algorithm') || event.failureReason?.includes('injection') ); }} // Monitoring queries to runconst SECURITY_QUERIES = { // Detect brute force attempts bruteForce: ` SELECT client_ip, COUNT(*) as failures FROM auth_events WHERE event_type = 'login' AND success = false AND timestamp > NOW() - INTERVAL '1 hour' GROUP BY client_ip HAVING COUNT(*) > 10 `, // Detect unusual refresh patterns suspiciousRefresh: ` SELECT user_id, COUNT(*) as refresh_count FROM auth_events WHERE event_type = 'refresh' AND timestamp > NOW() - INTERVAL '1 hour' GROUP BY user_id HAVING COUNT(*) > 100 `, // Detect token reuse attempts tokenReuse: ` SELECT * FROM auth_events WHERE failure_reason LIKE '%reuse%' AND timestamp > NOW() - INTERVAL '24 hours' `,};Use token IDs (jti) for log correlation, not full tokens. The jti uniquely identifies a token for investigation without exposing credentials. You can trace a token's lifecycle from creation through refresh to revocation.
Use this checklist to audit your JWT implementation. Every item should be explicitly addressed.
Key Security Principles:
Congratulations! You've completed the JWT Tokens module. You now understand JWT structure, claims design and validation, token expiration strategies, refresh token rotation, and critical security considerations. This comprehensive knowledge equips you to design and implement secure, production-grade authentication systems that withstand real-world attacks.