Skip to content
All posts
webJune 23, 20266 min read

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)
        break
Once you have the secret you are not forging - you are legitimately signing. Every claim in the token is now yours to set.

4. 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())
If it fails, try the DER form, the PEM without the trailing newline, and the raw modulus - the server’s exact byte representation is what must match.

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.

HeaderPurposeAttack
kidKey ID - selects a key from a setPath 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
jkuURL of a JWKS documentPoint it at a host you control and serve your own public key; also an SSRF primitive
x5uURL of an X.509 certificateSame as jku, with a certificate instead
jwkAn embedded public keySelf-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 only null is falsy.
  • Injection through claims. The sub or username claim often flows into a database query or a template. A signed token is still attacker-controlled input.
  • Weak `kid` collisions. If kid selects among a small key set, try every value; one of them may be a test key with a known secret.

A testing checklist

  1. Decode and read every claim. Note anything that looks like an authorisation decision.
  2. Modify one claim, leave the signature alone, and send it.
  3. Try alg: none and its case variants, with and without the trailing dot.
  4. Run a wordlist against the HMAC secret if the token is HS*.
  5. If it is RS*, find the public key and attempt HS256 confusion.
  6. Fuzz kid for traversal and injection; try jku, x5u, and jwk if the verifier honours them.
  7. Test an expired token, a future-dated token, and one with exp removed entirely.
  8. 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

jwtauthenticationalgorithm-confusionalg-nonekid-injectionweb