How does JWT algorithm confusion work?
A JSON Web Token carries its own instructions for how to verify it. The header names the algorithm in an alg field, and a permissive verification routine trusts that field. Algorithm confusion is what happens when the server decides how to check a signature based on a value the attacker controls.
A JWT is three base64url segments joined by dots: header, payload, signature. Decode the first two of a normal RS256 token:
{
"alg": "RS256",
"typ": "JWT"
}{
"sub": "user_4821",
"role": "user",
"iat": 1727390000,
"exp": 1727393600
}The server issued this token signed with its RSA private key, and it verifies incoming tokens with the matching RSA public key. Two mistakes turn that into a forgery.
RS256 to HS256 key confusion
RS256 is asymmetric: sign with the private key, verify with the public key. HS256 is symmetric: the same secret both signs and verifies. The RSA public key is not a secret. It is published in a JWKS document, embedded in a mobile app, or recoverable from two captured tokens.
If the verification code passes that public key into a generic verify() call without pinning the algorithm, an attacker can:
- Take the public key the server verifies with.
- Rewrite the header to
{"alg": "HS256", "typ": "JWT"}and edit the payload, for example"role": "admin". - Compute an HMAC-SHA256 signature over the new header and payload, using the public key bytes as the HMAC secret.
- Send it. The server sees
alg: HS256, reaches for its key, and runs HMAC with the public key as the secret. The attacker used the same key and secret, so it matches.
The exact bytes of the public key matter. As PortSwigger's research notes, the key used to sign must be byte-for-byte identical to the server's copy, "including non-printing characters like newlines." A wrong trailing newline is a common reason a valid-looking forgery fails. If no JWKS is exposed, the public key can be derived from two tokens with tooling such as sig2n.
alg=none
The JWT spec defines an unsecured token with alg: none and an empty signature. A verifier that honors it accepts a token with no signature at all:
GET /api/account HTTP/1.1
Host: acme.example
Authorization: Bearer eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyXzQ4MjEiLCJyb2xlIjoiYWRtaW4ifQ.The header decodes to {"alg":"none","typ":"JWT"}, the payload sets "role":"admin", and there are no bytes after the final dot. If the server accepts it, authentication is bypassed outright. Variants use casing (None, nOnE) to slip past a naive string check for the literal none.
Verified CVEs
These are real, checkable instances of the flaw in widely used libraries. Each says what version fixed it.
| CVE | Library | Issue | Fixed in |
|---|---|---|---|
| CVE-2015-9235 | jsonwebtoken (Node) | RS/ES token accepted when signed with an HS algorithm | 4.2.2 |
| CVE-2016-10555 | jwt-simple (Node) | decode did not enforce the algorithm, allowing RS-to-HS confusion | see advisory |
| CVE-2022-23540 | jsonwebtoken (Node) | verify without an algorithms option defaulted to allowing none | 9.0.0 |
| CVE-2022-29217 | PyJWT (Python) | algorithm confusion when decoding with all default algorithms | 2.4.0 |
Sources for these, opened and confirmed on NVD: CVE-2015-9235, CVE-2016-10555, CVE-2022-23540, CVE-2022-29217.
How do you test for it?
- Decode the token. Read the header. If
algisRS256or another asymmetric algorithm, key confusion is in scope; if the app has ever accepted it,noneis in scope too. - Try alg=none. Set the header to
{"alg":"none"}, keep or edit the payload, drop the signature (leave the trailing dot), and send it. Test casing variants. - Get the public key. Check
/.well-known/jwks.jsonand/jwks.json, pull it from a mobile bundle, or derive it from two captured tokens withsig2n. - Forge an HS256 token. Convert the public key to a raw secret, sign a modified payload with HMAC-SHA256 using those exact bytes, and send it. If it authenticates, the server is verifying with the algorithm the attacker chose. Escalate by editing a
roleortenantclaim to reach admin functions, which is broken function-level authorization through a forged identity, or by editing thesubclaim to act as another user, which is broken object-level authorization. - Try newline and encoding variants of the key if the first forgery fails, since a single differing byte breaks the HMAC.
How do you fix it?
Pin the accepted algorithm at verification time and never let the token's own header choose it.
Node, with jsonwebtoken 9.x:
const jwt = require("jsonwebtoken");
// Pass the public key AND lock the algorithm. Without algorithms,
// older versions defaulted to permissive behavior (CVE-2022-23540).
const payload = jwt.verify(token, rsaPublicKey, {
algorithms: ["RS256"],
issuer: "https://auth.acme.example",
audience: "acme-api",
});Python, with PyJWT 2.4.0 or later:
import jwt
# Always list the exact algorithms. Decoding with all default
# algorithms is what CVE-2022-29217 exploited.
payload = jwt.decode(
token,
rsa_public_key,
algorithms=["RS256"],
issuer="https://auth.acme.example",
audience="acme-api",
)The rules that hold across languages:
- Always pass an explicit allowlist of algorithms to the verify call. A single-entry list is best.
- Never accept
nonein production. Keep the signing keys and the verifying keys in separate types so an RSA public key can never be used as an HMAC secret path. - Upgrade the library. The CVEs above were fixed by removing the permissive defaults; an old version reintroduces them.
A WAF does not catch this. The forged token is a syntactically valid JWT with a valid signature under the algorithm the server chose to use; nothing about the request looks malformed.
[ Sources ]
Written by Parameter · Last reviewed

