JWT Security Vulnerabilities Code Review Guide
Table of Contents
Introduction
JSON Web Tokens (JWTs) have become the de facto standard for authentication and authorization in modern web applications. However, their widespread adoption has led to numerous security vulnerabilities when implemented incorrectly. Unlike session-based authentication, JWTs are stateless and self-contained, meaning all the information needed for validation is within the token itself.
This stateless nature is both JWT's greatest strength and its greatest weakness. While it enables scalable distributed systems, it also means that a compromised token cannot be easily revoked, and misconfigured validation can lead to complete authentication bypass.
Critical Impact
JWT vulnerabilities often lead to complete authentication bypass, allowing attackers to impersonate any user including administrators. A single JWT misconfiguration can compromise your entire application.
What makes JWT vulnerabilities particularly dangerous?
Understanding JWT Structure
A JWT consists of three Base64URL-encoded parts separated by dots: Header, Payload, and Signature. Understanding this structure is essential for identifying vulnerabilities during code review.
JWT Token Structure
Header
{"alg": "HS256", "typ": "JWT"}Algorithm & token type
Payload
{"sub": "123", "name": "John", "admin": false}Claims & user data
Signature
HMACSHA256(header + payload, secret)Integrity verification
JWT Components Decoded
1// Header (specifies algorithm and token type)
2{
3 "alg": "HS256", // Algorithm: HMAC SHA-256
4 "typ": "JWT" // Token type
5}
6
7// Payload (contains claims)
8{
9 "sub": "1234567890", // Subject (user ID)
10 "name": "John Doe", // Custom claim
11 "admin": true, // Authorization claim - DANGEROUS if not validated!
12 "iat": 1516239022, // Issued at
13 "exp": 1516242622 // Expiration time
14}
15
16// Signature (ensures integrity)
17HMACSHA256(
18 base64UrlEncode(header) + "." + base64UrlEncode(payload),
19 secret
20)JWTs Are NOT Encrypted
By default, JWTs are only signed, not encrypted. Anyone can decode and read the payload. Never store sensitive data like passwords, API keys, or PII in JWT payloads. Use JWE (JSON Web Encryption) if you need encrypted tokens.
What does the JWT signature protect against?
Algorithm Confusion Attacks
Algorithm confusion attacks exploit the way JWT libraries handle the 'alg' header. The two most critical attacks are the 'none' algorithm attack and the RS256/HS256 confusion attack.
The "none" Algorithm Attack
1// Original token with HS256
2{
3 "alg": "HS256",
4 "typ": "JWT"
5}
6
7// Attacker changes to "none" algorithm
8{
9 "alg": "none",
10 "typ": "JWT"
11}
12
13// Modified payload (attacker becomes admin)
14{
15 "sub": "1234567890",
16 "name": "Attacker",
17 "admin": true // Elevated privileges!
18}
19
20// Token with no signature (empty string after final dot)
21eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkF0dGFja2VyIiwiYWRtaW4iOnRydWV9.
22
23// Vulnerable server code
24const decoded = jwt.verify(token, secret); // May accept "none" algorithm!Algorithm Confusion Attack Flow
alg: HS256Uses public key as secretExpected: RS256Configured for RSASignature validToken acceptedWhy it works: When the server is configured for RS256 (asymmetric), the public key is used for verification. If the attacker changes the algorithm to HS256 (symmetric) and signs with the public key, the server uses the same public key as the HMAC secret, validating the forged token.
RS256 to HS256 Confusion Attack
1# Server is configured for RS256 (asymmetric)
2# Public key is available (e.g., from /.well-known/jwks.json)
3
4import jwt
5import requests
6
7# 1. Get the public key
8public_key = requests.get('https://target.com/.well-known/jwks.json').json()
9
10# 2. Create malicious payload
11payload = {
12 "sub": "admin",
13 "admin": True,
14 "exp": 9999999999
15}
16
17# 3. Sign with HS256 using public key as secret
18# The server will use its public key for HMAC verification!
19malicious_token = jwt.encode(
20 payload,
21 public_key, # Public key used as HMAC secret
22 algorithm='HS256',
23 headers={'alg': 'HS256'}
24)
25
26# 4. Server validates:
27# - Sees alg: HS256
28# - Uses "verification key" (public key) for HMAC
29# - Signature matches! Token accepted.How does the RS256/HS256 confusion attack work?
Weak Secret Keys
One of the most common JWT vulnerabilities is using weak or guessable secret keys. Tools like hashcat and jwt-cracker can brute-force weak secrets in seconds to minutes.
Common Weak Secrets
1# Secrets that can be cracked instantly:
2secret
3password
4123456
5your-256-bit-secret
6jwt_secret
7changeme
8development
9supersecret
10my-secret-key
11
12# Secrets from default configs:
13your-super-secret-key-here
14AllYourBase
15secret123
16qwerty
17
18# Environment-related (often committed to repos):
19process.env.JWT_SECRET (when undefined = "undefined")
20${JWT_SECRET} (template not replaced)Brute Force Reality
A weak 8-character secret can be cracked in under a minute on modern hardware. Tools like hashcat can test billions of HMAC combinations per second. Always use secrets of at least 256 bits (32+ random characters).
Cracking JWT Secrets
1# Using hashcat to crack JWT secrets
2# Mode 16500 = JWT (JSON Web Token)
3
4# Extract the token (everything except the signature for HS256)
5echo -n "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0In0" > jwt_hash.txt
6echo -n ":$(echo -n 'signature_base64' | base64 -d | xxd -p)" >> jwt_hash.txt
7
8# Run hashcat with wordlist
9hashcat -m 16500 jwt_hash.txt wordlist.txt
10
11# Using jwt-cracker (simpler tool)
12jwt-cracker <token> [alphabet] [maxLength]
13jwt-cracker "eyJhbG..." "abcdefghijklmnopqrstuvwxyz" 6What is the minimum recommended length for a JWT HMAC secret?
Token Expiration Issues
Improper handling of token expiration is a common vulnerability. This includes not setting expiration, using excessively long expiration times, or not validating the 'exp' claim on the server.
Expiration Vulnerabilities
1// VULNERABLE: No expiration set
2const token = jwt.sign({ userId: 123 }, secret);
3// This token is valid FOREVER until the secret changes
4
5// VULNERABLE: Expiration too long
6const token = jwt.sign(
7 { userId: 123 },
8 secret,
9 { expiresIn: '365d' } // 1 year - way too long!
10);
11
12// VULNERABLE: Not validating expiration
13const decoded = jwt.decode(token); // Just decodes, doesn't verify!
14// vs
15const verified = jwt.verify(token, secret); // Checks signature AND expiration
16
17// VULNERABLE: Trusting client-side validation only
18if (token.exp > Date.now() / 1000) {
19 // Client-side check can be bypassed!
20}
21
22// VULNERABLE: Ignoring expiration in verify options
23jwt.verify(token, secret, { ignoreExpiration: true }); // Never do this!Recommended Token Lifetimes
| Token Type | Recommended Lifetime | Use Case |
|---|---|---|
| Access Token | 15-60 minutes | API authentication |
| Refresh Token | 7-30 days | Obtaining new access tokens |
| Password Reset | 15-60 minutes | One-time use links |
| Email Verification | 24-48 hours | Account verification |
| Session Token | 1-24 hours | Web session management |
Access + Refresh Token Pattern
Use short-lived access tokens (15 min) with longer-lived refresh tokens (7 days). The refresh token should be stored securely (httpOnly cookie) and can be revoked server-side. This limits damage from stolen access tokens.
Finding JWT Vulnerabilities
During code review, focus on JWT library configuration, token generation, and verification logic. Look for common anti-patterns and misconfigurations.
Vulnerable Patterns to Look For
1// 1. Using decode() instead of verify()
2const payload = jwt.decode(token); // VULNERABLE - no signature check!
3
4// 2. Not specifying allowed algorithms
5jwt.verify(token, secret); // May accept any algorithm!
6
7// 3. Hardcoded or weak secrets
8const secret = 'my-secret-key'; // VULNERABLE
9const secret = process.env.JWT_SECRET || 'default'; // VULNERABLE fallback
10
11// 4. Accepting tokens from headers AND cookies without CSRF protection
12const token = req.headers.authorization || req.cookies.token;
13
14// 5. Not validating claims properly
15const decoded = jwt.verify(token, secret);
16// Missing: issuer (iss), audience (aud), subject (sub) validation
17
18// 6. Storing sensitive data in payload
19const token = jwt.sign({
20 userId: 123,
21 password: user.password, // NEVER DO THIS!
22 creditCard: user.cc // NEVER DO THIS!
23}, secret);
24
25// 7. Token in URL (logged in server logs, browser history)
26app.get('/api/data?token=eyJhbG...', handler); // VULNERABLESecure JWT Verification
1const jwt = require('jsonwebtoken');
2
3// Secure verification with all checks
4function verifyToken(token) {
5 try {
6 const decoded = jwt.verify(token, process.env.JWT_SECRET, {
7 // Explicitly specify allowed algorithms
8 algorithms: ['HS256'],
9
10 // Validate standard claims
11 issuer: 'https://myapp.com',
12 audience: 'https://api.myapp.com',
13
14 // Ensure expiration is checked (default, but be explicit)
15 ignoreExpiration: false,
16
17 // Clock tolerance for slight time differences
18 clockTolerance: 30 // seconds
19 });
20
21 // Additional custom validation
22 if (!decoded.sub || !decoded.role) {
23 throw new Error('Missing required claims');
24 }
25
26 return decoded;
27 } catch (error) {
28 // Log for monitoring but don't expose details
29 console.error('JWT verification failed:', error.message);
30 throw new Error('Invalid token');
31 }
32}What's wrong with using jwt.decode() for authentication?
Prevention Techniques
Securing JWT implementation requires attention to multiple areas: algorithm selection, secret management, claim validation, and token lifecycle management.
JWT Security Checklist
| Category | Requirement | Priority |
|---|---|---|
| Algorithm | Explicitly specify allowed algorithms | Critical |
| Algorithm | Reject "none" algorithm | Critical |
| Algorithm | Use RS256/ES256 for public clients | High |
| Secrets | Use 256+ bit random secrets for HMAC | Critical |
| Secrets | Rotate secrets periodically | High |
| Secrets | Never commit secrets to version control | Critical |
| Validation | Always verify() not decode() | Critical |
| Validation | Validate iss, aud, sub claims | High |
| Validation | Check expiration (exp claim) | Critical |
| Lifecycle | Use short expiration times | High |
| Lifecycle | Implement token revocation for refresh tokens | High |
| Storage | Store in httpOnly cookies (not localStorage) | High |
| Transport | Only transmit over HTTPS | Critical |
Secure JWT Implementation
1const jwt = require('jsonwebtoken');
2const crypto = require('crypto');
3
4// Generate a secure secret (do once, store in env)
5const generateSecret = () => crypto.randomBytes(32).toString('hex');
6
7// Secure token generation
8function generateTokens(user) {
9 const accessToken = jwt.sign(
10 {
11 sub: user.id,
12 role: user.role,
13 type: 'access'
14 },
15 process.env.JWT_ACCESS_SECRET,
16 {
17 algorithm: 'HS256',
18 expiresIn: '15m',
19 issuer: 'https://myapp.com',
20 audience: 'https://api.myapp.com'
21 }
22 );
23
24 const refreshToken = jwt.sign(
25 {
26 sub: user.id,
27 type: 'refresh',
28 jti: crypto.randomUUID() // Unique ID for revocation
29 },
30 process.env.JWT_REFRESH_SECRET,
31 {
32 algorithm: 'HS256',
33 expiresIn: '7d',
34 issuer: 'https://myapp.com'
35 }
36 );
37
38 // Store refresh token ID in database for revocation
39 await storeRefreshToken(user.id, refreshToken.jti);
40
41 return { accessToken, refreshToken };
42}
43
44// Secure cookie settings
45res.cookie('refreshToken', refreshToken, {
46 httpOnly: true, // Not accessible via JavaScript
47 secure: true, // HTTPS only
48 sameSite: 'strict', // CSRF protection
49 maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
50});Why should refresh tokens be stored in httpOnly cookies instead of localStorage?