JSON Web Tokens are everywhere in modern authentication stacks, and so are JWT implementation mistakes. The attacks aren’t theoretical — they’ve been used against real applications to bypass authentication entirely. This article covers the most exploited weaknesses and how to validate tokens correctly.
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
}
}
What to Fix First
Pin the algorithm in your verification code — never read it from the token header. That single change closes the alg:none attack and the RS256→HS256 confusion attack at once.
Beyond that: switch to RS256 or ES256 if you’re still on symmetric HMAC, disable jku/x5u header processing in your library config, and make sure iss, aud, and exp are validated on every request. Algorithm confusion bugs are library-level vulnerabilities, so keep PyJWT, python-jose, and jsonwebtoken updated.