JSON Web Tokens are everywhere, and so are JWT vulnerabilities. The attacks aren’t theoretical—they’ve been used against real applications to bypass authentication entirely. This guide covers the most exploited JWT weaknesses and how to implement correct validation.
JWT Structure Refresher
A JWT is three base64url-encoded parts joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0Iiwicm9sZSI6InVzZXIifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
[header] .[payload] .[signature]
The header specifies the algorithm. The signature proves the token was issued by someone with the signing key. When verification fails—or is skipped—the entire authentication model collapses.
Attack 1: The alg:none Bypass
Early JWT libraries respected an alg: "none" header field, meaning “this token is unsigned, accept it without verification.” An attacker could forge any payload:
import base64, json
# Craft a fake admin token with no signature
header = base64url_encode(json.dumps({"alg": "none", "typ": "JWT"}))
payload = base64url_encode(json.dumps({"sub": "admin", "role": "superadmin"}))
fake_token = f"{header}.{payload}." # Empty signature
# Many old libraries would accept this
The fix: Always specify the exact algorithm you expect. Never read the algorithm from the token itself to decide how to verify it.
# PyJWT — explicitly specify algorithm
import jwt
# WRONG — trusts the token's alg header
decoded = jwt.decode(token, secret, algorithms=jwt.get_unverified_header(token)['alg'])
# CORRECT — hardcode the expected algorithm
decoded = jwt.decode(token, secret, algorithms=["HS256"])
// jsonwebtoken (Node.js)
// WRONG
const decoded = jwt.verify(token, secret); // Uses whatever alg is in the header
// CORRECT
const decoded = jwt.verify(token, secret, { algorithms: ['HS256'] });
Attack 2: Weak Signing Secrets
HMAC-signed JWTs (HS256/HS384/HS512) are only as strong as the secret. Attackers can offline-brute-force tokens using tools like hashcat:
# hashcat mode 16500 = JWT
hashcat -a 0 -m 16500 captured.jwt /usr/share/wordlists/rockyou.txt
If the secret is secret, password, changeme, or any dictionary word, the token can be forged in seconds.
Generating a strong secret:
import secrets
# Minimum 256 bits for HS256
jwt_secret = secrets.token_hex(32) # 64 hex chars = 256 bits
# Or generate with openssl
openssl rand -hex 32
Better: switch to RS256 (asymmetric)
With RSA, the private key signs tokens (held only by your auth server) and the public key verifies them (can be shared with any service). Stealing the public key gains nothing.
# Signing (auth server only)
from cryptography.hazmat.primitives import serialization
private_key = open('private_key.pem').read()
token = jwt.encode(payload, private_key, algorithm='RS256')
# Verifying (any service with the public key)
public_key = open('public_key.pem').read()
decoded = jwt.decode(token, public_key, algorithms=['RS256'])
Attack 3: Algorithm Confusion (RS256 → HS256)
This subtle attack targets applications that support both RSA and HMAC algorithms. The attacker takes the server’s public key (which is public!) and uses it as the HMAC secret, then sets alg: HS256 in their crafted token.
# Attack: sign a token with the PUBLIC key as HMAC secret
attacker_token = jwt.encode(
{"sub": "admin", "role": "superadmin"},
public_key_bytes, # The server's public key — publicly available!
algorithm="HS256"
)
If the server says “this token says HS256, let me verify with my known key” and uses the public key as the HMAC secret, verification passes.
The fix: Pin the algorithm in your verification code. Never allow the token to choose between RS256 and HS256 dynamically.
// Node.js — pin to RS256 only
const EXPECTED_ALG = 'RS256';
function verifyToken(token, publicKey) {
return jwt.verify(token, publicKey, {
algorithms: [EXPECTED_ALG],
issuer: 'https://auth.myapp.com',
audience: 'myapp-api'
});
}
Attack 4: JWKS Endpoint Spoofing
Some libraries fetch the signing key from a jku (JWK Set URL) or x5u field in the JWT header. An attacker can craft a token pointing to their own JWKS endpoint:
{
"alg": "RS256",
"typ": "JWT",
"jku": "https://attacker.com/evil-keys.json"
}
The server fetches the attacker’s public key, and since the token was signed with the corresponding private key, verification succeeds.
The fix: Never use header-specified key URLs. Either hardcode the JWKS URL or maintain a strict allowlist.
# python-jose with explicit JWKS handling
from jose import jwt, jwk
import requests
JWKS_URL = "https://your-auth-server.com/.well-known/jwks.json" # Hardcoded
def verify_token(token):
# Fetch keys from trusted URL only — ignore jku in token
jwks = requests.get(JWKS_URL).json()
header = jwt.get_unverified_header(token)
# Find matching key by kid
key = next(k for k in jwks['keys'] if k['kid'] == header['kid'])
return jwt.decode(
token,
jwk.construct(key),
algorithms=['RS256'],
audience='myapp-api'
)
Proper Validation Checklist
Every JWT verification should check:
decoded = jwt.decode(
token,
public_key,
algorithms=["RS256"], # Pin the algorithm
issuer="https://auth.myapp.com", # Verify iss claim
audience="myapp-api", # Verify aud claim
options={
"verify_exp": True, # Reject expired tokens
"verify_nbf": True, # Reject not-yet-valid tokens
"verify_iat": True, # Verify issued-at
}
)
// Node.js complete verification
const options = {
algorithms: ['RS256'],
issuer: 'https://auth.myapp.com',
audience: 'myapp-api',
clockTolerance: 30, // 30 second tolerance for clock skew
};
try {
const decoded = jwt.verify(token, publicKey, options);
// decoded.sub is the verified user ID
} catch (err) {
if (err.name === 'TokenExpiredError') {
// Prompt re-authentication
} else {
// Treat as invalid — log the error
}
}
Key Takeaways
- Always hardcode the expected algorithm — never trust the token’s
algheader. - Use asymmetric keys (RS256/ES256) for stateless tokens; symmetric secrets must be long and random.
- Disable
jku/x5uheader processing unless you have a strict allowlist. - Validate
iss,aud, andexpclaims on every request, not just at login. - Use a well-maintained library (PyJWT, python-jose, jsonwebtoken) and keep it updated — algorithm confusion bugs are library-level vulnerabilities.