JWT attacks: alg confusion, key injection, and library failures
By Elias Lankinen11 min read
I have solid material from primary sources and four verified images. Writing the post now.
The token that trusts itself
In March 2015, a security researcher named Tim McLean published a short post that should have been an embarrassing footnote and instead became a decade-long recurring nightmare. He had been auditing libraries that handle JSON Web Tokens, the compact little credentials that by then were quietly becoming the default way for web applications to prove who you are. He found that many of them could be tricked into accepting forged tokens. Not through some exotic cryptographic break, but because the token got to tell the server how it should be checked, and the server believed it. Eleven years later, in March 2026, a maintainer shipped a patch for CVE-2026-27962, a critical (CVSS 9.1) flaw in Authlib, a widely used Python library. The bug: under the right conditions, the library would pull the verification key out of the attacker-controlled token itself and use it to check the signature. Different decade, different language, same fundamental mistake. An attacker signs a token with a key they made up, tells the server which key to use, and the server obliges. JSON Web Tokens are not insecure by design. But they sit on top of a family of standards so flexible that developers, and even seasoned library authors, keep stepping through the same trapdoors. Understanding why requires understanding what a token actually is.
A JWT (usually pronounced "jot") is three chunks of text joined by dots. Decode the first chunk and you get the header, a tiny JSON object naming the signing algorithm, like {"alg":"HS256","typ":"JWT"}. The second chunk is the payload: the actual claims, such as {"sub":"elias","role":"user","exp":1789900000}. The third chunk is the signature, a cryptographic seal computed over the first two. The whole thing is Base64url-encoded, which is just a URL-safe way of turning bytes into printable text. It is not encryption. Anyone who intercepts a JWT can read every claim inside it. The signature is the only thing standing between an honest token and a forged one.
That single design choice, letting the token declare its own algorithm in a header the server reads before it has verified anything, is the seam that every attack in this article pries open.
The original sin: "alg": "none"
The JWT specification, RFC 7519, and its underlying signature standard RFC 7515, both finalized in May 2015, include an algorithm value called none. It exists for legitimate reasons: sometimes a token is already protected by a secure transport channel, and re-signing it would be redundant. A none token has a header, a payload, and an empty signature. That is the whole design.
The problem McLean found was that several libraries treated a none token as validly signed. You could take a real token, decode it, change "role":"user" to "role":"admin", rewrite the header to say "alg":"none", delete the signature, and the library would wave it through as if the cryptography had checked out. There was nothing to check, and the library reported success anyway.
The fix sounds trivial: reject none unless you explicitly asked for it. But developers keep rediscovering the attack because the defenses keep being incomplete. As PortSwigger's Web Security Academy documents, servers that blocklist the literal string none can often be bypassed with mixed capitalization (nOnE) or unusual encodings, because somewhere in the stack the comparison is case-sensitive but the algorithm lookup is not. As recently as 2024, bug bounty hunters have reported full account and workspace takeovers built on nothing more than an alg:none token that a production service accepted.
The deeper lesson is that "the signature verified" and "the library returned without an error" are not the same statement, and a lot of code has quietly conflated them.
When the public key becomes the password
The second bug in McLean's 2015 post is subtler and, arguably, the most elegant attack in the JWT canon. To follow it you need the difference between two families of signing algorithms.
HS256 is symmetric. It uses HMAC-SHA256, a keyed hash where the same secret both creates and verifies the signature. Whoever can verify a token can also forge one, so the secret must stay secret.
RS256 is asymmetric. The server signs tokens with a private key that never leaves its custody, and anyone can verify those signatures using the matching public key. The public key is meant to be public. It is routinely published at a well-known URL like /.well-known/jwks.json so that other services can check tokens. Under RS256, knowing the verification key gives you no forging power at all. That is the entire point of asymmetric cryptography.
Now watch what happens when a library lets the token pick the algorithm.
A typical verification function accepts a key and figures out what to do with it from the token's alg header. The developer configured the server for RS256 and handed the function the RSA public key, reasoning, correctly, that a public key is harmless to expose. But the attacker sends a token whose header says "alg":"HS256". The library reads that, switches into symmetric mode, and treats the RSA public key as if it were an HMAC secret. It computes HMAC-SHA256(header.payload, publicKey) and compares.
Here is the kill shot: the attacker also has the public key, because it is public. So the attacker forges any payload they like, computes the HMAC using that public key as the secret, and produces a signature the server will accept. The public key, a value designed to be shared with the world, has been repurposed as the shared password. This is algorithm confusion, and it converts a token you cannot forge into one you can.
The attack has one finicky requirement, which is also why it sometimes fails in practice. As the Web Security Academy notes, the public key you sign with must be byte-for-byte identical to the copy the server holds, including format (usually X.509 PEM) and invisible characters like trailing newlines. Get a newline wrong and the HMAC won't match. When the public key isn't published anywhere, attackers can even reconstruct it from two legitimately signed tokens using a tool called rsa_sign2n, which computes candidate values of the RSA modulus.
This is not a museum piece. CVE-2023-48223 hit the Node.js library fast-jwt, where mismatched handling of PEM headers reopened algorithm confusion, and CVE-2024-54150 did the same for cjwt, a JWT library written in C. The pattern is durable because the root cause is not a coding slip; it is the interface. A verify(token, key) function that infers the algorithm from the token is a loaded gun pointed at the developer.
Keys smuggled in the envelope
The alg header is not the only field the token gets to fill in. The JOSE standards define several header parameters whose entire job is to help the server find the right verification key. Each one is a potential injection point when the server trusts it uncptically.
The most direct is jwk (JSON Web Key), a header field that lets a token carry its own public key. The intended use is niche. But if the server extracts the key from the jwk field and uses it to verify the token, the game is over before it starts: the attacker generates a fresh key pair, signs the forged token with their own private key, embeds the matching public key in the jwk header, and the server dutifully verifies the signature against the key the attacker just supplied. It always checks out. This is the exact flaw behind CVE-2018-0114 in Cisco's node-jose library, and it is the same trust failure that resurfaced in Authlib eight years later. RFC 7515 itself warns that the verification key MUST be determined by the application, not read out of the token.
A close cousin is jku (JWK Set URL), which points to a remote URL where the verification keys live. If the server fetches keys from whatever URL the token names, an attacker hosts their own key set on a domain they control and points jku at it. Even servers that try to restrict this to trusted domains often fall to URL-parsing tricks, and blindly fetching a token-supplied URL is itself a server-side request forgery risk that RFC 8725 explicitly calls out.
Then there is kid (key ID), a string that names which key from a set to use. Because that string frequently gets fed into something, its abuse potential is wide:
- If the server looks the
kidup in a database, it may be vulnerable to SQL injection, letting the attacker return a key of their choosing. - If the
kidis treated as a filename, path traversal like"kid":"../../../../dev/null"points the server at a predictable file. On many systems/dev/nullis empty, so the "key" is the empty string, and the attacker signs an HS256 token with an empty secret that verifies perfectly. The unifying theme is that the header was never trustworthy input. It arrives from the client, unverified, and yet it steers the very process meant to establish trust. The Web Security Academy's lab onjwkinjection exists precisely because this class of bug is so easy to reintroduce.
When the math itself lies
Every attack so far exploited how a library handled a token. In 2022 came one that broke the cryptography underneath, in one of the most widely deployed platforms on Earth.
Neil Madden, a security researcher then at ForgeRock, disclosed CVE-2022-21449, which he nicknamed "Psychic Signatures" after the psychic paper in Doctor Who that shows whatever the holder wants it to show. It affected ECDSA, the elliptic-curve signature scheme used by the ES256 family of JWTs, in Oracle's Java and OpenJDK.
An ECDSA signature is a pair of numbers, conventionally called r and s. Verification plugs them into an equation involving the message and the public key. The standard is explicit that both r and s must be at least 1; a value of zero is forbidden and must be rejected. When Oracle rewrote its elliptic-curve code from C++ into Java for Java 15, released in 2020, that check was dropped. As Madden put it, with r and s both zero, "you'd be checking that 0 = 0 ⨉ [a bunch of stuff], which will be true regardless of the value of [a bunch of stuff]."
The consequence is spectacular. A signature of all zeros verifies against any message and any public key. An attacker does not need to know or guess anything. They submit a blank signature, and Java's verifier declares it valid. Because ECDSA underpins not just JWTs but TLS certificates, SAML assertions, OIDC identity tokens, and most WebAuthn hardware keys, as JFrog catalogued, the blast radius covered a huge swath of Java-based authentication.
Madden reported it to Oracle on November 11, 2021; the fix shipped in the April 19, 2022 Critical Patch Update, leaving Java 15 through 18 exposed for months. The bug was not a JWT bug at all. But it landed on JWTs anyway, because a JWT is only ever as trustworthy as the signature primitive beneath it, and here the primitive itself had been taught to hallucinate.
Why the same mistake keeps returning
It is tempting to read this history as a parade of careless programmers. The more honest reading is that the standards optimize for flexibility, and flexibility is a tax paid in security bugs.
A JWT header is self-describing: it announces its algorithm, and may even carry or reference its own key. That makes JWTs wonderfully interoperable across systems that were never coordinated with each other. It also means a naive verifier's default behavior is to do what the attacker says. The safe path, pin the exact algorithm and the exact key out of band and ignore what the token claims, requires the developer to actively override the convenient default. Convenient defaults win, which is why PortSwigger observes that developers "accidentally introduce vulnerabilities even when using battle-hardened libraries."
This is also the misconception worth naming directly. People assume that because a JWT is signed, it is safe, that the signature is a property of the token. It is not. A signature is only meaningful relative to a specific key and a specific algorithm that the verifier insists upon. A token that gets to nominate its own algorithm and its own key has a signature in the same sense that a self-graded exam has a grade.
The standards body eventually wrote the lessons down. RFC 8725, "JSON Web Token Best Current Practices," published in February 2020, reads like a scar map of everything above. It insists that verifiers perform explicit algorithm verification, that "each key MUST be used with exactly one algorithm, and this MUST be checked," that libraries should neither produce nor consume none tokens unless explicitly asked, and that human-memorable passwords must never be used directly as HMAC keys, since a weak HS256 secret can simply be cracked with a wordlist and a tool like hashcat.
What to watch
The encouraging trend is that modern libraries increasingly demand the algorithm up front: you tell the verifier "this is HS256 and here is the key," and a token claiming anything else is rejected before its signature is even examined. That single interface change closes alg:none, algorithm confusion, and most header-injection variants at once.
The discouraging trend is that the vulnerable interface has not gone away, it has migrated. The Authlib flaw patched in 2026 shows the jwk-injection pattern is still shipping in current, actively maintained code, eight years after the identical bug hit node-jose. And the psychic-signatures episode is a reminder that even a perfectly written JWT library rests on cryptographic primitives that can themselves be quietly broken by a routine rewrite.
The open question is whether the ecosystem can outgrow the design that made these attacks possible without abandoning the interoperability that made JWTs popular. There are quieter alternatives, like PASETO, that deliberately remove the algorithm-agility knob, on the theory that the safest choice a token format can offer is no choice at all. Whether developers migrate, or simply keep patching the same wound each time it reopens, is the thing to watch. If the last eleven years are a guide, the next CVE in this family has probably already been written. It just hasn't been found yet.
Sources
- Tim McLean / chosenplaintext.ca, "Critical vulnerabilities in JSON Web Token libraries", 2015.
- Auth0, "Critical vulnerabilities in JSON Web Token libraries", 2015.
- PortSwigger Web Security Academy, "JWT attacks", accessed 2026.
- PortSwigger Web Security Academy, "Algorithm confusion attacks", accessed 2026.
- PortSwigger Web Security Academy, "Lab: JWT authentication bypass via jwk header injection", accessed 2026.
- IETF, RFC 7515: JSON Web Signature (JWS), 2015.
- IETF, RFC 7519: JSON Web Token (JWT), 2015.
- IETF, RFC 8725: JSON Web Token Best Current Practices, 2020.
- Neil Madden, "CVE-2022-21449: Psychic Signatures in Java", 2022.
- JFrog, "CVE-2022-21449 'Psychic Signatures': Analyzing the New Java Crypto Vulnerability", 2022.
- The Register, "Java deserialization bug... critical cryptographic flaw", 2022.
- GitHub Advisory Database, CVE-2026-27962: Authlib JWS JWK Header Injection, 2026.
- NVD, CVE-2018-0114: node-jose JWK header injection, 2018.
- NVD, CVE-2024-54150: cjwt algorithm confusion, 2024.