AppSec · Jun 26, 2026 · 6 min read

JWT algorithm confusion: forging tokens with a public key

When a server accepts the algorithm field from the token header, switching to HS256 lets you sign with the public key - which is already public.

Algorithm confusion is the rare bug where the attacker's key is already public. If a server trusts the alg field inside a token it has not yet verified, it can be talked into verifying an RS256 token as HS256 - using the RSA public key as the HMAC secret. The key was never secret, so the forgery is trivial. Confirming it safely, without minting a real privilege escalation, is the interesting part.

01The RS256 / HS256 split

RS256 is asymmetric: sign with a private key, verify with the public key. HS256 is symmetric: one shared secret does both. The vulnerability appears when a library reads alg from the untrusted header and picks the verification routine from it. Hand the server an HS256 token and it reaches for "the key" - which, for a service configured for RS256, is the public key it publishes.

intended alg=RS256 verify(token, PUBLIC key) signature must come from the private key ✓ attack alg=HS256 verify(token, PUBLIC key as HMAC secret) attacker signs with the public key ✗ forged
Same key material, wrong routine. The header chose the algorithm, so the attacker chose the trust model.

02Confirming it without forging real privilege

The proof bar is the same as any boundary test: a benign oracle, not a weaponized token. Take a token you legitimately hold, rewrite one claim to a detectable value that grants nothing - a marker in an unused field - re-sign it HS256 with the service's published public key, and check whether the server accepts the forged signature. Acceptance is the finding; you never set admin:true.

forge-oracle.pypython
import jwt, requests
pub = open("service_rs256_public.pem").read()  # already public
claims = decode_without_verify(token)
claims["_ap_probe"] = "62615533"   # benign marker, no privilege
forged = jwt.encode(claims, pub, algorithm="HS256")
r = requests.get(API, headers={"Authorization": "Bearer "+forged})
# 200 + our marker echoed back == signature accepted == confirmed
Proven - CriticalJWT algorithm confusion - forged signature accepted - /api/me
GET /api/me Authorization: Bearer <HS256 token signed with the RS256 public key> ──► 200 OK {"user":"alice","_ap_probe":"62615533"} same claims, RS256-signed by us ──► 401. Only the alg swap changed the outcome.
Oracle: benign marker in an unused claim. No privilege claim was set. Acceptance of a public-key-signed token confirms arbitrary claim forgery.

03One library's fix is not the ecosystem's fix

A scanner that only flags alg:none misses confusion entirely, and the safe defaults arrived on different timelines per library. The engine tests the specific behaviour rather than assuming a version is patched.

LibraryWhat to testHistorically weak default
node jsonwebtokenverify() without an explicit algorithms allowlistAccepted the header alg unless algorithms was pinned
PyJWTdecode() algorithm pinning and none rejectionRequired explicit algorithms= to be safe
ruby-jwtVerification key type vs declared algIndependent timeline for rejecting none and confusion

04Severity follows confirmation

A bypass that accepts forged claims on an authenticated endpoint is critical - arbitrary claim forgery is not theoretical once a marker round-trips. Before confirmation, when the endpoint merely echoes the algorithm without accepting the forged signature, it stays a reachability note, not a critical. The two are different facts and we keep them apart.

public
the key used to forge - by design
1
claim changed: a benign marker
alg
the only variable that flipped the result

Pin the accepted algorithms to the ones you actually issue, and the header stops being an input the attacker controls. Until then, the fix is one allowlist away and the proof is one marker away.

Evidence over heuristicsReachable beats foundMonitor → Block: rolling out a gate without slowing teamsIaC-grounded threat modelingAir-gapped AppSec with no phone-homeOne platform vs three tools
See it on your own app →