JWT Authentication Bypass and Algorithm Confusion Attacks
Server trust the token header to decide which algorithm verifies it, enabling forged credentials.

JWT authentication fails in a specific, repeatable way: the token tells the server how to check the token. That single design choice, baked into the spec from the start, is the root of algorithm confusion attacks, and it's why a public key can end up forging admin access. Understanding the full chain, from header to forged signature to privilege escalation, is the only way to write verification code that actually shuts the door.
JSON Web Tokens do one job. A server issues a signed, compact credential at login, the client stores it, and every later request gets checked against that signature, no session database required. RFC 7519 standardized the format years ago, and it's now everywhere: OAuth 2.0 flows, OpenID Connect, and a wide range of API architectures lean on it somewhere.
The structure is three parts, separated by dots: header, payload, signature, each one Base64url-encoded. The header carries metadata, including a field called alg, short for algorithm. That field is small. It's also the entire attack surface this piece is about.
JWTs spread fast because stateless verification scales sideways. No shared session store, no sticky sessions, no coordination between servers. Microservices and single-page apps needed a credential that could move between services without a phone call to a central database, and JWT gave them one, cheap to implement and easy to pass around. But every service that accepts a JWT is a verification endpoint on its own, and the more of those there are, the more places a forged token might get waved through. The vulnerability class covered here involves flaws baked into the specification's design choices. It's a tension written into the specification itself.
The order of operations that makes the specification itself exploitable
Verification, as the spec lays it out, runs in a fixed order:
- Decode the Base64url header.
- Read the
algfield from that header. - Pick the verification function based on what
algsays. - Fetch the key.
- Run the cryptographic check.
Step two is where things go wrong. The server reads the algorithm off the token before it verifies anything. The thing being checked is handing instructions to the checker.
RFC 7519 made alg a token-controlled field on purpose. That single decision gave anyone holding a token a say in how the server verifies it. RFC 8725, published years afterward, tries to patch over some of this. Its existence alone shows that the original spec shipped a trust assumption that real deployments couldn't hold up.
The gap is between "the spec allows the server to trust the header" and "the server must never trust the header." Algorithm confusion lives exactly in that space. And this isn't the usual story of a developer skipping a step. Someone can read RFC 7519 line by line, implement it faithfully, and still ship something an attacker walks straight through.
The RS256-to-HS256 confusion attack turning a public key into a forged credential
Start with the normal case. RS256 signs with an RSA private key on the server side; anyone verifying the token uses the matching RSA public key. Public keys are, by definition, not secret. Plenty of systems publish them at predictable spots: /.well-known/jwks.json, /jwks.json, /.well-known/openid-configuration, /api/v1/jwks. PortSwigger's Web Security Academy even runs a lab where the key sits right at /jwks.json, wide open, as the starting point for the whole exploit.
Once an attacker has that public key, two edits do the rest. Change alg in the header from RS256 to HS256. Change the payload to say whatever's useful, "sub": "administrator" being the obvious pick. Then sign the new header and payload with HMAC-SHA256, using the RSA public key's raw bytes as the HMAC secret.
The math cooperates for a straightforward reason. HMAC-SHA256 doesn't care what the key represents. If it is fed any string of bytes, it will happily compute a hash. It has no idea those bytes came from an RSA public key instead of a random secret, and HS256's whole security model rests on the key being kept private. An RSA public key was never meant to be private in the first place.
So what does a vulnerable server do when this token lands? It reads "alg": "HS256" from the header, routes into the HMAC verification path, and pulls its stored key material, the same RSA public key, to use as the HMAC secret. It computes the identical HMAC the attacker already computed. The signatures match. The forged claims get trusted. Admin access, and the private key was never touched.
Nothing about the cryptography broke here. RSA works fine. HMAC works fine. The failure is that the server let the token pick which verification path to run.
This has been documented in production systems since 2015. It's been documented in production systems since 2015. The Node.js jsonwebtoken library, before version 4.2.2, was open to an alg:none bypass tracked as CVE-2015-9235: verify() accepted unsigned tokens and trusted the algorithm named in the token header by default. A related variant of the same problem hung around even longer, not fully closed until version 9.0.0, under CVE-2022-23541.
And it's not history. Keycloak disclosed CVE-2026-11800 on June 25, 2026: algorithm confusion inside the JWT Authorization Grant flow that lets an attacker holding valid client credentials skip signature verification, forge assertions, and impersonate any federated user tied to the affected identity provider, landing at privilege escalation. It hit versions 26.6.0 through 26.6.3, introduced in 26.6.0, patched in 26.6.4, rated 8.1 on CVSS. Eleven years after the pattern first showed up publicly, it's still getting found in identity software that plenty of enterprises run.
The broader family of header-trust attacks that follow the same logic
Every attack in this family shares one trait: the server trusts something the token itself supplied to decide how, or whether, to verify it.
alg:none is the simplest version. The JWT spec actually defines none as a legitimate algorithm for unsecured tokens, meant for cases where signing genuinely isn't needed. A dispatcher that naively branches on whatever alg says can end up skipping verification entirely. The token becomes header.payload., trailing period, empty signature field, nothing to check. Libraries that block the literal string none sometimes miss the case variants, nOnE, NoNE, NONE, because whoever wrote the comparison forgot to lowercase first. This still occurs in custom implementations in 2026. Apache Pulsar had a reported instance of skipping signature verification outright on tokens carrying alg=none.
JWK header injection works on a similar seam. The JWS spec allows an optional jwk header parameter that embeds the signing key right inside the token. An attacker generates their own RSA key pair, drops the public half into that jwk field, and signs with the matching private key. A vulnerable server reads the key straight from the token's own header and checks the signature against it, attacker-controlled key, attacker-controlled signature, passes every time. This attack pattern has a well-documented history in real-world JWT implementations.
JKU, short for JWK Set URL, is the same trick with a network hop added. Instead of using a locally pinned key set, the server fetches one from whatever URL the jku header points to. Host a JWKS file somewhere the attacker controls, point jku there, sign with the matching private key, and the server dutifully fetches the attacker's keys and verifies against them. Prefix-matching makes this worse: a server that checks whether jku starts with https://auth.acme.com can be fooled by https://auth.acme.com.evil.com, since that string does, technically, start with the expected prefix.
The kid parameter, short for key ID, causes trouble when it's passed unsanitized into a database lookup or a filesystem read to select which key to use. Slip a UNION SELECT payload into kid and the database might hand back an attacker-chosen value as the "key." Setting kid to /dev/null causes the server to read zero bytes, letting the attacker sign with an empty HMAC secret. If kid ever touches a shell command, injection there can expose private keys outright or lead to remote code execution. Web application firewalls often miss all of this because the kid field arrives Base64url-encoded inside the header, and a scanner checking the raw HTTP request without decoding it never sees the payload. Separately, CVE-2025-30204 in the golang-jwt library showed that a malformed Authorization header, Bearer followed by a long run of periods, triggers excessive memory allocation during header parsing. No authentication needed, low complexity, exploitable over the network.
One more failure mode doesn't involve the spec at all, just careless code. Most JWT libraries ship two separate functions: decode(), which parses the claims and skips the signature check, and verify(), which actually validates it. Code that calls decode() inside an auth middleware accepts any structurally valid token, no error thrown, no signature checked. It happens more than it should, usually because tutorial code built around decode() for debugging gets copied straight into production, and the function name sounds close enough to "verify" that it slides past code review.
Two attack classes that don't touch the algorithm field at all
Not every JWT compromise runs through the alg field. Weak HMAC secrets are their own problem. If the signing secret is short or guessable, an attacker who captures a single valid token can crack the key offline using tools like hashcat, on ordinary consumer hardware, no special access required. Once the key's cracked, the attacker can sign any token they want, indefinitely, with zero further contact with the server. The baseline defense is a long, randomly generated secret, produced with a cryptographically secure generator. Real cases keep surfacing: CVE-2025-7079 found the literal string bluebell-plus hardcoded into jwt.go in the bluebell-plus project. CVE-2025-6950 found a hardcoded JWT signing key baked into Moxa network security devices and routers. A hardcoded secret is functionally a public secret, whether anyone's published it or not.
Audience and issuer confusion is a quieter failure mode. A library can pin the algorithm correctly, reject alg:none, do everything right on that front, and still accept a token that was never meant for it. Picture one auth service issuing tokens with a single private key, shared across several backend services that each hold the matching public key. An attacker with a legitimate token, issued for Service A, where they happen to have admin rights, submits that same token to Service B. The signature checks out, the expiry is fine, and if Service B never checks the aud claim, it just honors the token. Service A's admin privileges get treated as valid on a system meant to grant that user nothing more than read access. The fast-jwt library, before version 5.0.6, had a related flaw: it allowed the iss claim to be a string array even though RFC 7519 specifies it should be a single string, letting an attacker mix a legitimate issuer and a malicious one in the same array to slip past issuer checks. Audience validation is, in practice, one of the claim checks most often left out entirely.
CVE-2022-21449, nicknamed "Psychic Signatures," is a different kind of failure altogether: not a spec problem, not a config mistake, a bug in the cryptographic library that touched everything built on top of it. It hit Oracle Java SE 17.0.2 and 18, Oracle GraalVM Enterprise Edition 21.3.1 and 22.0.0.2, and OpenJDK versions 15 through 18 before the April 2022 patch. ECDSA signatures are supposed to require both components, called r and s, to be non-zero. A refactored code path in the affected JDK versions accepted a signature made entirely of zeroes as valid, for any public key, against any message. Any JWT using ECDSA-based signing, running on one of these vulnerable JDKs, could be forged just by submitting a token whose signature segment decoded to all zeroes. Oracle patched it in the April 2022 critical patch update, but until then, every JWT library running on top of a vulnerable Java runtime inherited the flaw, no matter how carefully that library itself was configured. The lesson sits a level below the application: the verification stack is only as trustworthy as the cryptographic primitive running underneath it, and runtime CVEs belong in the JWT threat model just as much as library misconfiguration does.
Verification layer requirements to close the attack surface
Pin the algorithm on the server, and don't let the token anywhere near that decision. That's the core fix for confusion attacks. The server needs to decide, before it ever reads the incoming token, exactly which algorithm it will accept, whether that's RS256 or something else, one value, set as an explicit allowlist. Reject anything else outright, none included, and normalize the string to lowercase before comparing, so nOnE and NoNE don't sneak past a case-sensitive check. The verification call should run against the algorithm the server has configured, never against whatever value the header happens to claim.
Keep key material separated by algorithm type, no exceptions. An RSA key pair should never double as an HMAC secret, not as a fallback, not in a test environment either, since test shortcuts have a way of ending up in production. Store and name keys so it's structurally difficult to hand the wrong one to the wrong verification function.
Header parameters like jku, jwk, x5u, and kid deserve the same treatment: turn them off in the library configuration if the application has no real need for dynamic key resolution. If dynamic resolution genuinely is required, for something like key rotation across a federated identity setup, then jku values need a strict allowlist of exact hosts, not a prefix check that a suffix-matching domain can slide through, and any value fed into a kid lookup needs to go through the same sanitization as a query parameter, because that's effectively what it is. None of these defenses are exotic. They separate a system that lets the token set the terms of its own inspection from one that decides the terms in advance and never budges.