Parameter

JWT algorithm confusion

Also known as

  • JWT key confusion
  • RS256 to HS256 confusion

JWT algorithm confusion is a vulnerability where an attacker changes a token's alg header so the server verifies it with the wrong algorithm, signing an RS256 token with the public key as an HMAC secret or setting alg to none, and forging a valid token.

Category
API security
OWASP
API2:2023 Broken Authentication
Last reviewed

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:

  1. Take the public key the server verifies with.
  2. Rewrite the header to {"alg": "HS256", "typ": "JWT"} and edit the payload, for example "role": "admin".
  3. Compute an HMAC-SHA256 signature over the new header and payload, using the public key bytes as the HMAC secret.
  4. 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.

CVELibraryIssueFixed in
CVE-2015-9235jsonwebtoken (Node)RS/ES token accepted when signed with an HS algorithm4.2.2
CVE-2016-10555jwt-simple (Node)decode did not enforce the algorithm, allowing RS-to-HS confusionsee advisory
CVE-2022-23540jsonwebtoken (Node)verify without an algorithms option defaulted to allowing none9.0.0
CVE-2022-29217PyJWT (Python)algorithm confusion when decoding with all default algorithms2.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?

  1. Decode the token. Read the header. If alg is RS256 or another asymmetric algorithm, key confusion is in scope; if the app has ever accepted it, none is in scope too.
  2. 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.
  3. Get the public key. Check /.well-known/jwks.json and /jwks.json, pull it from a mobile bundle, or derive it from two captured tokens with sig2n.
  4. 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 role or tenant claim to reach admin functions, which is broken function-level authorization through a forged identity, or by editing the sub claim to act as another user, which is broken object-level authorization.
  5. 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 none in 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.

Written by Parameter · Last reviewed