Attacking JWTs: alg=none, algorithm confusion, and the header fields nobody audits
A JSON Web Token is a signed claim you were handed and asked to give back. Every classic JWT bug is a place where the verifier lets the token choose how it is verified - alg=none, RS256 to HS256 confusion, kid injection, and attacker-hosted key URLs.
A JWT is three base64url segments joined by dots: header, payload, signature. The header says which algorithm signed it, the payload carries the claims, and the signature covers header.payload as a literal string. Nothing is encrypted - anyone can read a JWT - so the entire security property is the signature check.
// header
{ "alg": "HS256", "typ": "JWT" }
// payload
{ "sub": "1234", "username": "guest", "admin": false, "exp": 1893456000 }
// signature
HMACSHA256(base64url(header) + "." + base64url(payload), secret)- header - alg, kid, jku - how to verify
- payload - sub, role, exp - what you are
- signature - over the first two, dot included
header
alg, kid, jku - how to verify
payload
sub, role, exp - what you are
signature
over the first two, dot included
Look closely at the header. The token tells the server how to verify the token. (A JWT is also just one of several shapes a session token takes - sessions, cookies, CORS and CSRF covers the rest and how the token is carried.) That single design decision is the root of nearly every JWT vulnerability below; the rest is a catalogue of what happens when a verifier believes it.
1. Read the payload first
Before attacking anything, decode and read. The claims tell you what the application believes about you and, crucially, what it might believe if you changed something: "admin": false, "role": "user", "user_id": 1002. The target of your forgery is usually sitting right there in plain text.
Also note the standard claims. exp (expiry), nbf (not before), iat (issued at), iss (issuer), and aud (audience) are all optional to check, and plenty of applications check none of them. An expired token that still works is a finding on its own, and it is the cheapest one to test.
2. alg=none
The JWT specification includes an alg value of none, meaning 'unsecured token, no signature'. It exists for cases where integrity is guaranteed by another layer. A verifier that honours it on an authentication token accepts anything you write.
# Header: {"alg":"none","typ":"JWT"} Payload: {"user":"admin","admin":true}
# Signature: empty. Note the trailing dot - it is required.
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJ1c2VyIjoiYWRtaW4iLCJhZG1pbiI6dHJ1ZX0.Libraries patched this years ago by rejecting the literal string none, so try the variants: None, NONE, nOnE. A case-sensitive blocklist paired with a case-insensitive algorithm lookup is a real bug pattern that still appears. Also try sending the token with the signature removed entirely but the trailing dot kept, and with the dot removed too - different parsers disagree about which of those is well-formed.
3. Weak HMAC secret
HS256 signs with a shared secret, and shared secrets in tutorials, sample apps, and CTF challenges are words - so this is a cracking problem, not a cryptographic one. secret, password, key, changeme, the framework's default, the application's name. A wordlist pass against the signature is fast because verification is a single HMAC - there is no network round trip and no rate limit.
import hmac, hashlib, base64
def b64(b: bytes) -> bytes:
return base64.urlsafe_b64encode(b).rstrip(b'=')
header_payload, sig = token.rsplit('.', 1)
target = base64.urlsafe_b64decode(sig + '=' * (-len(sig) % 4))
for word in open('rockyou.txt', 'rb'):
word = word.strip()
mac = hmac.new(word, header_payload.encode(), hashlib.sha256).digest()
if hmac.compare_digest(mac, target):
print('secret =', word)
break4. Algorithm confusion: RS256 to HS256
This is the elegant one. RS256 is asymmetric: the server signs with a private key and verifies with a public key, and the public key is public by design. HS256 is symmetric: the same key signs and verifies.
server signs RS256
private key, public key published
you fetch the public key
/jwks.json, a cert, or two tokens
re-sign as HS256
HMAC, keyed with those exact PEM bytes
verifier uses it as a secret
the public key was never secret
Now suppose the verifier is written as verify(token, key) where key is the RSA public key and the algorithm is taken from the token's header. Change the header to HS256 and sign the token using the public key bytes as the HMAC secret. The server reads alg: HS256, performs an HMAC with the key material it has - the public key - and it matches. You just signed a token with a key everyone is allowed to know.
import hmac, hashlib, base64, json
# The exact PEM bytes the server uses - formatting matters, including the
# trailing newline. Fetch it from /jwks.json, a TLS certificate, or the repo.
pubkey = open('public.pem', 'rb').read()
def b64(o) -> bytes:
raw = o if isinstance(o, bytes) else json.dumps(o, separators=(',', ':')).encode()
return base64.urlsafe_b64encode(raw).rstrip(b'=')
header = b64({"alg": "HS256", "typ": "JWT"})
payload = b64({"user": "admin", "admin": True})
signing_input = header + b'.' + payload
sig = b64(hmac.new(pubkey, signing_input, hashlib.sha256).digest())
print((signing_input + b'.' + sig).decode())5. The header fields nobody audits
Beyond alg, the JOSE header can carry pointers to the key itself. Each is a place the token tells the server which key to trust.
| Header | Purpose | Attack |
|---|---|---|
kid | Key ID - selects a key from a set | Path traversal (../../../dev/null gives an empty key you can HMAC with), SQL injection if keys live in a database, command injection if it reaches a shell |
jku | URL of a JWKS document | Point it at a host you control and serve your own public key; also an SSRF primitive |
x5u | URL of an X.509 certificate | Same as jku, with a certificate instead |
jwk | An embedded public key | Self-sign the token and embed the matching key; vulnerable verifiers trust the token's own key |
The kid traversal deserves a moment because it is unintuitive. If kid is used as a filesystem path to load key material, pointing it at a file with predictable contents means you know the key. /dev/null is empty, so sign with the empty string. On some systems /proc/sys/kernel/ostype reliably contains Linux\n. Any file whose contents you can predict works.
For jku and jwk, the defence is an allowlist of trusted key sources. Where there is no allowlist, generate a keypair, host the JWKS (or embed the JWK), sign with your private key, and the server dutifully fetches your public key and verifies successfully.
6. Everything else that goes wrong
- Signature not checked at all. Change a claim, leave the signature untouched, send it. Sounds absurd; happens regularly in code paths that decode a token for logging and then reuse the decoded object.
- Claims trusted after a failed check. Some code decodes first, verifies second, and uses the decoded value in between.
- No expiry enforcement. A stolen or leaked token works forever, and a CTF token from the challenge description may still be live.
- Confusable claim types.
{"admin": "false"}is a non-empty string, which is truthy in several languages. So is{"admin": 0}in others where onlynullis falsy. - Injection through claims. The
suborusernameclaim often flows into a database query or a template. A signed token is still attacker-controlled input. - Weak
kidcollisions. Ifkidselects among a small key set, try every value; one of them may be a test key with a known secret.
A testing checklist
Read every claim
Decode first. Note anything that looks like an authorisation decision.
Change one claim, keep the signature
Send it. A surprising number of verifiers never check.
Try
alg: noneAnd its case variants, with and without the trailing dot.
Attack the secret
Run a wordlist against the HMAC secret if the token is
HS*.Try algorithm confusion
If it is
RS*, find the public key and re-sign as HS256.Fuzz the header fields
kidfor traversal and injection;jku,x5uandjwkif the verifier honours them.Attack the clock
An expired token, a future-dated one, and one with
expremoved entirely.Check logout
Is the token invalidated when the session ends? Frequently it is not.
The pattern behind all of it, if you want one sentence to carry away: a verifier must decide the algorithm and the key from its own configuration, never from the token. Every bug above is that rule being broken in a slightly different place.