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.
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.
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.
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
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.
| Library | What to test | Historically weak default |
|---|---|---|
| node jsonwebtoken | verify() without an explicit algorithms allowlist | Accepted the header alg unless algorithms was pinned |
| PyJWT | decode() algorithm pinning and none rejection | Required explicit algorithms= to be safe |
| ruby-jwt | Verification key type vs declared alg | Independent timeline for rejecting none and confusion |
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.
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.