JWT algorithm confusion and the alg field you should never trust
RS256-verified-as-HS256 with the public key as the HMAC secret, the alg=none variants, kid injection, and why correct verification pins the algorithm from JWKS instead of the token.
Every JWT attack in this post comes from one mistake: letting the token choose how
it is verified. The header is attacker-controlled, the verifier reads alg from it,
and a library that obeys that field lets you pick the weakest check available. Pin
the algorithm server-side and the whole class disappears. Trust the header and it is
wide open.
Algorithm confusion: RS256 verified as HS256
This is the one worth knowing cold. The server issues RS256 tokens, verified with
an RSA public key, which by definition is not secret, it is often at
/.well-known/jwks.json. The attack: change the header to HS256 and sign the token
with HMAC using the PEM-encoded public key as the secret.
A verifier that reads alg from the header sees HS256, fetches “the key” for this
issuer (the RSA public key, the only key it has), and runs HMAC-SHA256 with it. You
hold that same public key. So your HMAC matches, and the server accepts a token you
forged with entirely public material. No private key needed.
# 1. grab the issuer's public key (JWKS or the TLS cert chain)
curl -s https://app.example.com/.well-known/jwks.json
# 2. reconstruct the PEM, then forge: header alg=HS256,
# HMAC the signing input with the PEM bytes as the secret
# payload claims an admin subjectThe forged token claims whatever you want, {"sub":"admin","role":"admin"}, and
verifies clean. That is a full authentication bypass from public data.
The alg=none family
Older or misconfigured verifiers honour alg: none, which declares the token
unsigned and, if accepted, means anyone can mint anything. Strip the signature,
keep the dot:
eyJhbGciOiJub25lIn0.eyJzdWIiOiJhZG1pbiJ9.Test the casing variants too, since blocklists tend to be naive: none, None,
NONE, nOnE. A filter that checks for the exact lowercase string and then
lowercases-and-accepts is its own bug.
kid injection
kid (key ID) tells the verifier which key to use, and verifiers sometimes turn it
into a file path or a database lookup without sanitising it. That makes it an
injection point in its own right:
"kid": "../../../../dev/null" key resolves to empty, sign with empty secret
"kid": "/path/to/a/known/file" point at a predictable-content file as the secret
"kid": "1' UNION SELECT 'attacker-secret" SQL injection into key lookupIf kid selects a file whose contents you can predict (or force empty), you control
the HMAC secret and forge freely. If it flows into SQL, you may return a key of your
choosing. Either way the root cause is the same as everything above: the token is
steering the verification.
Testing it fast
- Decode the token, read the real
alg. If it isRS256/ES256, the public key is the target, so try the HS256 confusion path first. - Send
alg: noneand its casings. Watch for acceptance. - Fuzz
kidwith traversal, a known-content path, and a quote, to probe path and SQL handling. - Flip one claim (
role,sub,is_admin) and confirm whether re-signing under the confused algorithm is honoured. A forged token that changes a privilege claim and still verifies is the proof.
What correct verification looks like
The fix is one sentence: pin the algorithm from your key material, never from the
token. Resolve the key from JWKS by issuer, take the algorithm from that key’s
metadata, and reject any token whose header disagrees. An RS256 key verifies
RS256 and nothing else; an HS256 header against an RSA key is refused before any
crypto runs. Pass an explicit algorithm allowlist to the library, never the
permissive “decode and trust the header” call. Once the server decides the algorithm,
the attacker’s control over alg, none, and kid buys nothing.