JWT Tokens
JWT Security Considerations
Read aloud is unavailable in this browser.
The Security Minefield
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 Algorithm Confusion Attack
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:
- Server uses RS256 (asymmetric): private key signs, public key verifies
- Attacker obtains the public key (often publicly available)
- Attacker creates a forged token with
alg: HS256 - Attacker signs the token using the public key as an HMAC secret
- Vulnerable server sees HS256, uses the 'key' (actually the public key) for HMAC verification
- Signature matches! Attacker has forged a valid token.
1234567891011121314151617181920212223242526272829303132
// 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 "none" Algorithm Attack
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.
Key Injection Attacks
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:
- •Never fetch keys from URLs in tokens — Use statically configured JWKS endpoints only
- •Whitelist JWKS URLs — If you must support dynamic keys, maintain a strict whitelist
- •Ignore embedded keys (jwk, x5c) — Only use keys from your trusted key store
- •Use kid for key selection — Map kid to pre-configured keys, never to token-provided keys
- •Cache and pin keys — Don't re-fetch on every request; cache keys and rotate on schedule
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;}Secure Token Storage
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 |
- •Any JavaScript can read localStorage
- •XSS attacker can steal all tokens
- •No expiration — tokens persist forever
- •Shared across tabs (amplifies attack surface)
- •Never store JWTs in localStorage
- •JavaScript cannot access httpOnly cookies
- •XSS cannot directly steal tokens
- •Automatic expiration via cookie Max-Age
- •Automatic transmission on same-origin requests
- •Store refresh tokens in httpOnly cookies
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.
Protecting Against Common Attacks
Beyond the specific JWT vulnerabilities, several general attack patterns target token-based authentication.
Cross-Site Scripting (XSS)
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:
- Store sensitive tokens in httpOnly cookies (not accessible to JS)
- Keep access tokens in memory only (limits exposure window)
- Implement Content Security Policy (CSP)
- Sanitize all user input (output encoding)
- Use modern frameworks with built-in XSS protection
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 damageToken Sidejacking (Session Fixation Variant)
Attacker intercepts or predicts a token and uses it before the legitimate user.
Defenses:
- Always use HTTPS (HSTS)
- Bind tokens to client characteristics (user agent, IP range)
- Implement device fingerprinting for sensitive actions
- Short token lifetimes limit sidejacking window
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;}Replay Attacks
Attacker captures and reuses a valid token.
Defenses:
- Short expiration times (smaller replay window)
- One-time use tokens for sensitive operations
- Nonce + timestamp for critical requests
- Token binding (detect client change)
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.
Key Management Security
The security of JWT signatures depends entirely on the security of signing keys. Key compromise = complete authentication bypass.
- •Never hardcode keys — Always load from environment or secrets manager
- •Use strong key generation — At least 256 bits of entropy for HMAC, 2048+ bits for RSA
- •Rotate keys regularly — Have a rotation plan and test it
- •Separate keys by environment — Production, staging, development should never share keys
- •Limit key access — Principle of least privilege for who can access signing keys
- •Audit key usage — Log when keys are used, alert on anomalies
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.
Logging and Monitoring
Effective security requires visibility. You must be able to detect attacks, investigate incidents, and understand authentication patterns.
- •Authentication success/failure
- •Token creation events (sub, type, exp)
- •Token refresh events (jti rotation)
- •Token validation failures (reason)
- •Unusual patterns (rapid refreshes, etc.)
- •Security events (revocation, reuse)
- •Full token strings (bearer credentials!)
- •Signing secrets
- •Token payloads with sensitive claims
- •Passwords or credentials
- •Full stack traces to end users
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.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.