JWT Algorithm Confusion Attacks in Production APIs

Attackers forge tokens by exploiting how servers verify JWT signatures.

Contributing Editor · · 13 min read
Cover illustration for “JWT Algorithm Confusion Attacks in Production APIs”
Authentication Flaws · September 16, 2026 · 13 min read · 2,818 words

JWT algorithm confusion attacks bypass authentication on real production APIs, and they've recurred since 2015 despite a documented fix. The vulnerability doesn't live in the JWT format itself. It lives in verification logic, and that logic breaks in three mechanically distinct ways: alg:none, confusion between two signing algorithms, and a JVM bug that has nothing to do with JWT at all. Treat these as one bug and testing misses two of the three. Treat them separately and each one has a clean, specific fix.

The standard security advice, "never trust the alg header," is true. It's also useless on its own, because it flattens three different root causes into a single warning and sends testers chasing the easiest one to find while the other two sit open in production.

How JWT signature verification is supposed to work, and where the design creates risk

A JWT is three Base64url chunks glued with dots: header, payload, signature. That signature is the only thing standing between a token and a forgery. Everything else, the claims, the header fields, is just text anyone can read and edit.

Verification is supposed to run like this. Decode the header, check the alg field, pick the right verification function, pull the matching key (usually from a JWKS endpoint), then run the crypto check. Step two is where things go wrong. If the server lets the token's own alg claim decide which verification path it runs, the attacker is choosing the server's logic for it. If the server lets the token's own alg claim decide which verification path it runs, the attacker is choosing the server's logic for it, which is backwards. The server should already know what algorithm it expects.

Part of the problem is baked into how JWT libraries are built. Decoding a token and verifying a token are two separate operations, but a call like jwt.verify(token, key) hides an assumption: that the token can't influence how key gets used. When a library lets a developer skip specifying the algorithm, most developers do skip it, and the library quietly falls back to reading whatever algorithm the token claims for itself. That's the whole bug, in one missing parameter.

Stateless design makes the fallout worse. JWT-based auth typically holds no server-side session, so there's no built-in way to revoke a single token without extra infrastructure sitting on top. The signature is the only fact the server can check. If that check can be steered by the attacker, there's nothing else backing it up.

The alg:none bypass, how it works and why it almost never appears in modern production

The original JWT spec allowed alg:"none" for cases where a token didn't need a signature at all, meant for trusted, closed environments. In practice, this turned into a free pass. Change the header's alg field to "none," strip the signature, leave the trailing dot, and send it. No key, no cracking, no exploit chain. Just edit a string.

Some libraries tried to block the literal word "none" and got beaten by case variation: "nOnE," "NoNE," "NONE." Any string check that isn't case-insensitive can be walked around with capitalization alone. Auth0 ran into exactly this: an uppercase E in "alg:nonE" slipped past a simple string comparison.

Tim McLean's 2015 write-up on this got node-jsonwebtoken, PyJWT, namshi/jose, and php-jwt all patched fast. Every current, correctly configured version of those libraries now rejects alg:none by default, and re-enabling it usually takes an explicit flag like allowInsecureAlgorithm.

It's also trivial to catch. Burp Suite's scanner flags it, and the JWT Editor extension's Attack feature runs the case-variant checks automatically. So where does it still show up? Custom-built JWT parsers, old SDKs nobody's updated, and lab environments built to demonstrate the bug. Not in a properly configured library shipped after 2015.

That matters, because alg:none is usually the first thing any JWT testing checklist covers. When it comes back clean, testers move on. When it comes back clean, testers move on, and that is the mistake. Passing the alg:none test proves nothing about the other two attack classes.

RS256 to HS256 confusion, the variant that keeps appearing in shipped code

Diagram: The RS256-to-HS256 Confusion Exploit Path. Visualizes: Visualize the five-step exploitation sequence for RS256-to-HS256 algorithm confusion attacks.

RS256 and HS256 solve different problems, and confusing them collapses the security guarantee entirely.

RS256 is asymmetric: a private key signs, a public key verifies. The point is that only the private key holder can mint valid tokens. HS256 is symmetric: one shared secret does both signing and checking. Algorithm confusion attacks exploit the gap between these two models, because a public key is, by definition, public. Anyone can get it.

Here's the exploitation path, step by step. Pull the server's RSA public key from its JWKS endpoint (usually something like /.well-known/jwks.json, no login required). Build a JWT with "alg":"HS256" in the header and whatever claims serve the attack. Sign it using HMAC-SHA256, but use the RSA public key's raw bytes as the HMAC secret. Send it to the server. The server reads "HS256" from the header, routes to HMAC verification, grabs what it thinks is its secret (which is really its own public key), and runs the check. The math checks out. Verification passes.

The fix is straightforward on paper: pin the algorithm server-side, so the server ignores whatever alg the token claims and only accepts what it's configured to expect. In practice, that pinning step gets skipped constantly, because most library APIs don't force it.

The CVE record backs this up with specifics. CVE-2016-10555 hit jwt-simple for Node.js; the fix landed in version 0.3.1 and required an explicit algorithms option on the server. CVE-2022-29217, rated 7.5, affected PyJWT from version 1.5.0 through 2.3.0, where non-blocked public key formats slipped through in algorithm confusion scenarios; fixed in 2.4.0.

More recent, and more telling: CVE-2024-54150 hit Comcast's xmidt-org/cjwt project. A code review of the cjwt_decode() function found it read the alg field from the header and dispatched, through jws_verify_signature(), straight to verify_hmac(), with no check on whether the key it was handed was actually a public key. Classic RS256/HS256 confusion, confirmed in production code, in 2024, almost a decade after the pattern was first documented.

CVE-2023-48223 in fast-jwt came down to a regex problem: the publicKeyPemMatcher pattern didn't catch every valid PEM format for public keys, leaving an opening for HS256 confusion specifically when a key carried a "BEGIN RSA PUBLIC KEY" header instead of the more common format.

And on the bug bounty side, a 2024 HackerOne report against 8x8's connect.8x8.com API found the same thing in the wild: the JWT verifier accepted HS256 tokens signed with the RSA public key as the HMAC secret, with no algorithm pinning in place. Admin tokens got forged with zero access to the private key.

For anyone testing this directly, ticarpi's jwt_tool has a flag for exactly this scenario: -X k signs a token using HMAC-SHA256 with a supplied public key as the secret, fed in locally as a PEM file via -pk. JWKS-fetching is handled separately, through modes like -X s.

The real lesson from the Comcast case is that functional testing won't catch this. CVE-2024-54150 lived inside code that almost certainly had test coverage and went through review. The bug sits in the verification control path, not in the logic that normal tests exercise. A test suite checks whether valid tokens get accepted. It rarely checks whether the server can be tricked into using the wrong verification method entirely.

ECDSA Psychic Signatures, when the bug is in the JVM, not the JWT library

This one isn't a JWT bug at all. It's a bug in how certain Java versions did elliptic curve math, and it happened to make JWTs (along with a lot else) forgeable.

CVE-2022-21449, CVSS 7.5, came out on April 19, 2022, disclosed by Neil Madden at ForgeRock alongside Oracle's patch. The root cause: Java 15 rewrote its elliptic curve cryptography code, moving it from C++ to pure Java. Somewhere in that rewrite, a bounds check got dropped, the one confirming that the signature values r and s are non-null and smaller than the curve's order, n.

Here's why that check matters. ECDSA verification runs a calculation roughly like: (hash times s-inverse mod n) times G, plus (r times s-inverse mod n) times Q, and the result should equal R. Set both r and s to zero, and every term in that equation collapses to zero. The equation becomes 0 equals 0. That holds true no matter what the message says or whose public key is involved.

So a signature that's just 64 bytes of zeroes passes verification, on any unpatched JDK from version 15 through 18, for any algorithm built on the same elliptic-curve signing scheme: ES256, ES384, ES512. It doesn't matter how well the JWT library pins its algorithms or how carefully the JWKS endpoint is locked down. The flaw in the JVM's own math causes the forged signature to validate before any of that logic even runs.

Confirmed affected builds included Oracle JDK 17.0.2, JDK 18, and GraalVM Enterprise Edition 21.3.1 and 22.0.0.2, with JFrog tracing the root cause to the same language port that introduced it in Java 15. And this reaches well past JWT: anything calling java.security.Signature.verify() for ECDSA through Java's built-in crypto provider was exposed, including TLS certificate checks and OAuth/OIDC flows.

Library-side fixes came, but they couldn't fully close the gap. Nimbus's com.nimbusds.jose library shipped mitigations in version 9.22 that blocked some attack paths, but couldn't stop every CVE-2022-21449 vector on a JVM that was still unpatched. Auth0's java-jwt fixed things in 3.19.2. Even so, plenty of applications kept running older versions for months after Oracle's patch went out, because dependency upgrades don't happen automatically just because a CVE gets published.

The distinction that matters here: unlike RS256/HS256 confusion, this attack needs no public key, no JWKS access, and no misconfigured library. The only requirement is an unpatched JDK 15 through 18 somewhere in the stack. A 2020 industry guidance document on JWT best practices tells developers to pin algorithms. It says nothing about bugs sitting inside the cryptographic primitives themselves, because that's a layer below anything a specification can control.

The JWT header attack surface beyond alg, kid injection, jku injection, and embedded jwk keys

Three more header fields share the same failure pattern as alg: the server trusts something the attacker can edit to decide how it resolves cryptographic material.

Start with kid, the Key ID field. It's just a string, with no fixed structure defined anywhere in the JWS spec. Servers use it however they like: a database lookup key, a filesystem path, an index into a JWK set. If that string gets passed into a database query without sanitizing it first, that's a SQL injection point sitting inside a JWT header. Path separators like ../ or / signal a traversal attempt; things like quotes, --, UNION, SELECT, or OR signal an injection attempt. Most WAFs won't catch this by default, because the kid field arrives Base64url-encoded inside the header, and pattern matching against the raw HTTP request checks the encoded string rather than the decoded one that carries the attack.

Then there's jku, the JWK Set URL. If a server fetches whatever URL the token's jku field points to and trusts the keys it gets back, without checking that URL against an allowlist first, that's two attack paths in one field. An attacker can host a forged key set on their own server and point jku there, or use the fetch itself as a server-side request forgery primitive against internal infrastructure. CVE-2026-48522 found exactly this in PyJWT's PyJWKClient: no scheme validation on the jku URL, meaning file://, ftp://, and even data: URIs all got accepted, letting an attacker redirect the fetch to an SSRF target or hand over a forged JWK Set through a data URI directly. Related but distinct, some library implementations have also exposed key-resolution redirect paths through improper claim validation.

Last is the risk of trusting embedded keys: jwk and x5c header fields let a token carry its own public key or certificate chain right inside the header, for the server to verify against. If the verifier actually trusts that embedded key instead of looking one up independently, an attacker can generate a fresh keypair, sign a forged token with the private half, and embed the matching public half in the header. Verification then checks the signature against a key the attacker just made up, and of course it passes. CVE-2018-0114 in node-jose documented this pattern directly. Any verifier built to stand up to this has to ignore jwk and x5c headers entirely once an issuer has a static trust store or a JWKS URI configured. There's no legitimate reason to let the token supply its own verification key when the server already knows where to find the real one.

Weak HMAC secrets and claim tampering, the misconfiguration layer below algorithm confusion

Even when HS256 is set up correctly, algorithm pinned, library current, everything routed the way it should be, the entire guarantee rests on one thing: how strong and how secret that shared HMAC secret actually is.

That's often where things fall apart anyway. Developers forget to swap out a placeholder secret before deployment. Tutorial code gets copied with its example secret still attached. Or the secret is something predictable: a product name, a date, a string like "MyAppSecret2024" that someone typed in five minutes before a demo and never changed. Tools like hashcat, running mode 16500, can throw billions of guesses a second at an HS256 token on ordinary hardware today. A 16-character secret built on a predictable pattern can fall in well under a day.

Once a signature check gets bypassed, whether through a weak secret or an algorithm confusion attack, claim tampering is what turns that crack into a full breach:

Role escalation: flip a role claim to include "admin," or add write and delete permissions that weren't there before. If the server accepts it, that's Broken Functional Level Authorization, plain and simple. Missing exp claim: a token with no expiration never needs renewing, which means compromised credentials can't really be rotated out. An account breached months back can still be sitting on a token that works today. Missing aud claim: a token issued for one service gets accepted by a different one entirely. That's lateral movement across microservices, and it doesn't take an exploit, just reusing a token where it was never meant to go.

The stakes shift depending on the industry. Under HIPAA, bypassing NBF and EXP checks opens the door to replay attacks against patient data APIs. In fintech, PCI compliance falls apart fast once the HMAC secret protecting a token can be cracked in minutes rather than months.

Auth0's CVE-2018-6873 stands as a case study because of how far it reached. The aud parameter simply wasn't validated, which meant any Auth0 account holder could use their own valid token to log into someone else's account, needing nothing more than that person's email address. Paired with a companion CSRF bug, CVE-2018-6874, which handled delivering the forged token, the impact wasn't limited to one app or one customer. It touched the platform's entire customer base at once, because the flaw sat in shared verification logic, not in any single tenant's configuration.

None of these misconfigurations tend to occur alone. A team that got algorithm handling wrong is, more often than not, the same team that skipped aud validation too. These bugs cluster, because they come from the same underlying habit: trusting the token more than the server's own configuration.

The CVE record from 2022 through 2026, why new instances keep appearing despite the documented fix

The fix for algorithm confusion has been public and stable since 2015: pin the algorithm server-side, never let the token pick its own verification method. And yet the CVE record from 2022 through 2026 shows the same pattern recurring again and again, appearing in PyJWT, in fast-jwt, in Comcast's cjwt, and in multiple production deployments, and in an unrelated JVM cryptography rewrite that had nothing to do with JWT until it did.

That persistence isn't really about the fix being unclear. It's about the fix living one layer below where most development and testing attention sits. Functional tests check whether the right token gets accepted. They don't check whether the wrong verification path can be triggered instead, and that path only shows up when someone treats alg:none, RS256/HS256 confusion, and the ECDSA arithmetic bug as three separate things with three separate causes, rather than one vague warning to keep in mind.

Each variant needs a different check. Confirming a library blocks alg:none says nothing about whether the same server pins its algorithm against HS256 substitution. Confirming algorithm pinning is in place says nothing about which JDK version is sitting underneath it, running the actual signature math. The fix for one does not cover the other two, and new instances of all three keep landing in production code because most testing checklists stop after confirming the first.

Sources

  1. JWT Algorithm Confusion: RS256 to HS256, Psychic Signatures, and alg:none on Production APIs
  2. JWT Security: How Misconfigured Tokens Expose Your APIs
  3. Another JWT Algorithm Confusion Vulnerability: CVE-2024-54150
  4. JWT algorithm confusion attacks: How they work and how to prevent them — WorkOS
  5. Algorithm confusion attacks | Web Security Academy
  6. jfrog.com
  7. neilmadden.blog
  8. portswigger.net

More in Authentication Flaws