Skip to content
All posts
webrevised June 23, 20267 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)
  1. header - alg, kid, jku - how to verify
  2. payload - sub, role, exp - what you are
  3. signature - over the first two, dot included
Three base64url segments, joined by dots. Every attack below is a consequence of the first segment: the token is carrying the instructions for checking the token.

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)
        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.

  1. server signs RS256

    private key, public key published

  2. you fetch the public key

    /jwks.json, a cert, or two tokens

  3. re-sign as HS256

    HMAC, keyed with those exact PEM bytes

  4. verifier uses it as a secret

    the public key was never secret

The confusion is not in the crypto, it is in one parameter: the verifier takes the algorithm from the token and the key from its config, and the two no longer agree about what that key is.

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. Read every claim

    Decode first. Note anything that looks like an authorisation decision.

  2. Change one claim, keep the signature

    Send it. A surprising number of verifiers never check.

  3. Try alg: none

    And its case variants, with and without the trailing dot.

  4. Attack the secret

    Run a wordlist against the HMAC secret if the token is HS*.

  5. Try algorithm confusion

    If it is RS*, find the public key and re-sign as HS256.

  6. Fuzz the header fields

    kid for traversal and injection; jku, x5u and jwk if the verifier honours them.

  7. Attack the clock

    An expired token, a future-dated one, and one with exp removed entirely.

  8. 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.