JWT Algorithm Confusion Attacks: RS256 to HS256, none, and JWK Injection
Algorithm confusion is one of the most reliable full-authentication-bypass primitives in modern web and API testing, and it survives in production far longer than it should. The root cause is a design seam in the JOSE specification: a JWT's alg header tells the verifier how to check the signature, but the verifier often picks the verification routine from that attacker-controlled field instead of from its own configured policy. When the same physical key material can be fed to two different algorithms — an RSA public key into an HMAC routine, for example — the attacker controls which code path runs and can forge tokens at will.
This post is a focused, hands-on walkthrough of the algorithm-confusion family for authorized engagements: the classic RS256→HS256 swap, the none algorithm bypass, embedded-JWK and jku/x5u key injection, the EС/key-type confusion variants, and the validation logic that actually stops all of them. Everything below assumes you are testing a system you have written permission to test. For broader coverage of token manipulation see our JWT attacks pentester's guide; this article zooms in on the cryptographic confusion class specifically.
Why the alg header is the whole problem
A JWT is three base64url segments: header.payload.signature. The header declares the signing algorithm, and a permissive verifier dispatches on it:
// The dangerous pattern (pseudocode of many real libraries)
const header = decode(token.split('.')[0]);
const verifier = pickAlgorithm(header.alg); // attacker-controlled!
return verifier.verify(signingInput, signature, key);
Two algorithm families share the stage. HMAC (HS256/384/512) is symmetric — the same secret signs and verifies. RSA and ECDSA (RS*, PS*, ES*) are asymmetric — a private key signs, a public key verifies, and the public key is, by definition, public. The confusion attack exploits the fact that a verifier expecting an asymmetric scheme can be tricked into running a symmetric one, with the now-public verification key repurposed as the HMAC secret.
RS256 to HS256: forging with the public key
This is the headline technique. Suppose the application issues RS256 tokens and verifies them with an RSA public key it considers safe to expose. If the verification function is something like verify(token, publicKeyPem) and it honors the token's alg header, you can:
- Change the header
algfromRS256toHS256. - Tamper with the payload (e.g. set
"role": "admin"or swap thesub). - Compute an HMAC-SHA256 over the new
header.payloadusing the RSA public key bytes as the HMAC key.
The server, treating the request as HS256, runs HMAC-SHA256(publicKey, signingInput) — the exact computation you just performed — and the signature matches. The critical, easily-missed detail is the exact key representation: it must be the byte-for-byte PEM string the server holds, including the -----BEGIN PUBLIC KEY----- header, footer, and trailing newline. A single missing newline changes the HMAC and the forgery fails.
# 1. Obtain the public key — often at a JWKS endpoint
curl -s https://target.example/.well-known/jwks.json
# 2. If you only have the JWK, reconstruct the PEM (n, e -> SPKI)
# openssl / python cryptography can rebuild it deterministically.
# 3. Forge with jwt_tool using the captured public key as the HMAC secret
python3 jwt_tool.py <TOKEN> -X k -pk public.pem
If no JWKS is published, you can sometimes derive the RSA public key from two captured RS256 tokens signed by the same private key (tools like rsa_sign2n recover candidate moduli from the signatures). That turns a "we never expose the key" assumption into a non-defense. To experiment with malformed headers and crafted claims before you have a target, our JWT generator builds confusion and none-algorithm tokens with arbitrary claims, and the encoder/decoder lets you hand-inspect each base64url segment.
The none algorithm bypass
The simplest member of the family. JOSE defines "alg": "none" for unsecured tokens that carry no signature. A verifier that does not explicitly forbid none will accept a token with an empty signature and any payload you like:
{"alg":"none","typ":"JWT"}.{"sub":"1","role":"admin"}.
Note the trailing dot with nothing after it — the signature segment is empty. Many libraries historically had a path where, if the configured key was absent or the alg resolved to none, verification short-circuited to success. Always fuzz the casing, because string comparisons are frequently case-sensitive while the algorithm lookup is not: try none, None, NONE, and nOnE. A blocklist that only checks for the lowercase literal will wave the capitalized variants straight through.
JWK, jku, and x5u header injection
The JOSE header can carry its own key. Three header parameters are the usual culprits:
jwk— an embedded public key inside the token header. If the verifier trusts it, generate your own RSA keypair, embed the public half asjwk, sign the tampered token with your private key, and it validates.jku— a URL the server fetches to retrieve a JWKS. Point it at infrastructure you control and serve a key whose private half you hold.x5u— same idea but referencing an X.509 certificate chain.
{
"alg": "RS256",
"typ": "JWT",
"jwk": { "kty":"RSA", "n":"<your-modulus>", "e":"AQAB" }
}
For jku/x5u, server-side fetching also opens an SSRF surface and frequently a parser-confusion gap: a server that "validates" the host with a substring check (url.includes("trusted.com")) is defeated by https:https://attacker.example/trusted.com or https://trusted.com.attacker.example, and userinfo tricks like https://[email protected]. Treat any token that fetches remote key material as a high-severity finding until proven safe.
kid manipulation and key-source confusion
The kid (Key ID) header tells the verifier which key to select. Because that value is often used to build a file path or a SQL lookup, it is an injection sink as much as a confusion vector:
- Path traversal to a predictable key:
"kid": "../../../../dev/null"can make the server sign/verify with an empty key — then forge an HS256 token with an empty secret. - SQL injection:
"kid": "x' UNION SELECT 'secret'-- -"returns an attacker-known value that becomes the verification key. - Coercing a public key into the secret slot: a
kidthat resolves to a readable public-key file, combined with anHS256alg, recreates the RS256→HS256 swap without touching JWKS.
ECDSA introduces an additional twist: CVE-2022-21449 ("Psychic Signatures") let Java's ES256/384/512 verifier accept a signature with r = s = 0, meaning a token with a literally blank ECDSA signature validated. The pattern repeats — verification logic that does not enforce non-degenerate inputs collapses to "any signature is fine."
A repeatable testing methodology
Work through the family systematically rather than spraying payloads:
- Fingerprint the alg. Decode a legitimate token and note the declared algorithm and whether a
kid/jku/jwkheader is present. Use the JWT decoder & builder to inspect and re-sign without leaving the browser. - Probe none. Submit
nonein all casings with an empty signature and a minimally-elevated claim. A 200 with elevated access is game over. - Attempt the RS→HS swap. Pull the public key from JWKS (or recover it from two tokens), forge HS256 with the exact PEM bytes, and confirm acceptance. Re-test with and without a trailing newline.
- Test header key injection. Embed a
jwk, then tryjku/x5upointing at your collaborator host; watch for the outbound fetch. - Abuse kid. Traversal to
/dev/null, SQLi, and pointing at a known-public key. - Brute weak HMAC secrets. If the system genuinely uses HS256, capture a token and crack it offline —
hashcat -a 0 -m 16500 jwt.txt rockyou.txt. Verify recovered secrets with HMAC mode in the hash generator.
Defenses and remediation
Every attack above traces back to trusting attacker-controlled fields. The fixes are concrete:
- Pin the algorithm at the verifier. Pass an explicit allowlist and never read it from the token. Most libraries support this — for example
jwt.verify(token, key, { algorithms: ["RS256"] }). If the configured scheme is asymmetric, the verifier must refuse HS* outright, which kills the confusion swap. - Type-bind the key to the algorithm. An RSA public key must never be accepted by an HMAC verifier. Use APIs that take a typed key object (a public key, not a raw string) so that feeding it to HS256 is a type error, not a silent key reuse.
- Reject
noneunconditionally unless you have a deliberate, isolated reason for unsecured tokens — and even then, never on a security-bearing path. - Do not trust header-supplied keys. Ignore
jwk; resolvekidonly against a server-side allowlist of known IDs; and if you must usejku/x5u, restrict to an exact, scheme-and-host-validated allowlist with no remote fetching of arbitrary URLs. - Treat
kidas untrusted input. Use parameterized lookups, canonicalize and constrain paths, and reject anything outside an expected character set. - Use strong, rotated HMAC secrets — at least 256 bits of entropy from a CSPRNG — and patch your JOSE library so CVE-class signature-verification bugs are closed.
For a printable command reference covering these payloads, keep the JWT attacks cheat sheet open during testing, and pair it with the OAuth/OIDC cheat sheet when JWTs are issued as part of a broader identity flow. Confusion bugs are cheap to find and devastating in impact — and once the verifier stops listening to the alg header, the entire class disappears at once.
Put this into practice
Generate and test these payloads interactively — free, in your browser.
Level up your security testing
Install the CLI
npx payload-playgroundExplore All Tools
Encoding, hashing, JWT & more
Browse Cheat Sheets
Quick-reference payload guides