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)Look closely at the header. The token tells the server how to verify the token. 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. 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.
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 `kid` collisions. If
kidselects among a small key set, try every value; one of them may be a test key with a known secret.
A testing checklist
- Decode and read every claim. Note anything that looks like an authorisation decision.
- Modify one claim, leave the signature alone, and send it.
- Try
alg: noneand its case variants, with and without the trailing dot. - Run a wordlist against the HMAC secret if the token is
HS*. - If it is
RS*, find the public key and attempt HS256 confusion. - Fuzz
kidfor traversal and injection; tryjku,x5u, andjwkif the verifier honours them. - Test an expired token, a future-dated token, and one with
expremoved entirely. - Check whether the token is invalidated on logout. 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.
Further reading
- PortSwigger Web Security Academy: JWT attacks - Free labs for every attack in this post, with working targets
- RFC 7519 - JSON Web Token - Including the `none` algorithm and the security considerations section
- Critical vulnerabilities in JSON Web Token libraries (Auth0, 2015) - The original write-up of alg=none and RS256/HS256 confusion