3 min readJWT · CRYPTO · AUTHENTICATION

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.

bash
# 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 subject

The 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:

plaintext
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:

plaintext
"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 lookup

If 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

  1. Decode the token, read the real alg. If it is RS256/ES256, the public key is the target, so try the HS256 confusion path first.
  2. Send alg: none and its casings. Watch for acceptance.
  3. Fuzz kid with traversal, a known-content path, and a quote, to probe path and SQL handling.
  4. 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.