JWT Vulnerabilities: From "alg: none" to Algorithm Confusion in 2026
In late March 2015, a security researcher named Tim McLean published a short blog post titled "Critical vulnerabilities in JSON Web Token libraries" that named names. He had spent the prior weeks reading the source of every major JWT implementation he could find — node-jsonwebtoken, pyjwt, jjwt, php-jwt, and a handful of smaller packages — and had found two distinct bug classes shipping in production defaults across the ecosystem. The first was that several libraries accepted tokens with the header {"alg":"none"} as cryptographically valid: a token whose signature segment was empty, whose verification routine returned true, whose payload could be anything the attacker chose. The second was that libraries which advertised support for both HMAC and RSA happily verified an HS256 token using whatever key the application had passed in — including, if the application had passed an RSA public key on the assumption that the token was signed with RS256, the public key as an HMAC secret. McLean disclosed responsibly, the libraries patched within weeks, and yet eleven years later both bug classes still ship in fresh code and in the next authentication service near you.
This article walks through how the alg: none and HS256/RS256 confusion attacks look in real Node.js and Python, why CVE-2022-21449 turned the same lesson into a single-character bypass on the Java platform, and what the audience-and-issuer antipattern does to multi-tenant systems. The bug class refuses to die because the safe configuration is verbose and the verify API on most libraries lets a developer skip the parts that matter without the type system noticing.
The JWT Format in One Paragraph
A JSON Web Token is three base64url-encoded segments separated by dots. The first segment is a header declaring the signing algorithm, typically {"alg":"HS256","typ":"JWT"} for symmetric HMAC or {"alg":"RS256","typ":"JWT"} for asymmetric RSA. The second segment is the payload, a JSON object containing claims like sub, iss, aud, exp, and whatever application-specific fields the issuer added. The third segment is the signature: a MAC computed over header.payload with the algorithm named in the header. Verification is conceptually two operations — recompute the signature using the trusted key, then compare it — but the library API often collapses both into a single call that returns the parsed payload on success. The collapse is where everything goes wrong.
The "alg: none" Attack
The JWT specification, in an unfortunate nod to flexibility, defines an algorithm value of none for unsigned tokens. The intent was to allow tokens whose integrity was guaranteed by a different layer to ride the same parser. The reality was that several major libraries, on receiving a token with {"alg":"none"}, treated the empty signature segment as cryptographically valid and returned the payload to the caller. An attacker who took any signed token, swapped the header for {"alg":"none"}, dropped the signature, and resubmitted it would pass verification. The classic vulnerable shape in Node.js still appears in production because the jsonwebtoken library's verify function accepts the algorithm from the token header when the caller does not pass an algorithms allowlist:
// VULNERABLE
const jwt = require('jsonwebtoken');
function authenticate(token) {
// No algorithms parameter — the library trusts the header.
const payload = jwt.verify(token, process.env.JWT_SECRET);
return payload;
} On a sufficiently old jsonwebtoken build the call above accepts alg: none and returns the attacker's payload. Modern versions reject none by default but still pick the verification algorithm from the header if no allowlist is provided, which is the entry point for the algorithm-confusion attack discussed below. The fix is to always pass an explicit algorithms array and to validate the issuer and audience claims at the same call:
// FIXED
const jwt = require('jsonwebtoken');
function authenticate(token) {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'https://auth.example.com',
audience: 'https://api.example.com',
});
return payload;
}Algorithm Confusion: HS256 With the Public Key
The second bug McLean disclosed in 2015 is more devious because it survives a library that has patched alg: none. Consider an application that issues RS256 tokens: the auth server signs with an RSA private key, the resource server verifies with the matching public key. RSA public keys are, by definition, public — they sit in JWKS endpoints and OpenID Connect discovery documents. Now suppose the verification code passes that public key into a library that picks the algorithm from the token header. The attacker swaps the header to {"alg":"HS256"} and signs the new header.payload with HMAC-SHA256 using the public key bytes as the secret. The library, told by the header to verify with HS256, computes HMAC over the same bytes with the same key, gets a match, and returns the payload. The same vulnerable call signature surfaces in Python:
# VULNERABLE
import jwt
def authenticate(token: str, public_key: str) -> dict:
# No algorithms parameter — PyJWT picks from the header.
payload = jwt.decode(token, public_key)
return payload On older PyJWT versions the call above accepts whatever algorithm the header declares, which means an attacker who knows the public key — and they always do — can forge any token they want. Modern PyJWT raises if no algorithms argument is passed, but applications that wrap the call in a try/except to "be robust" silently revert to the broken behavior on a typo. The fix is to pin the algorithm explicitly and validate the issuer and audience in the same call:
# FIXED
import jwt
def authenticate(token: str, public_key: str) -> dict:
payload = jwt.decode(
token,
public_key,
algorithms=['RS256'],
issuer='https://auth.example.com',
audience='https://api.example.com',
)
return payload The same shape applies to Java's jjwt: a builder configured with setSigningKey alone trusts the header, while the safe equivalent calls require on the issuer and audience and uses a key resolver that refuses any algorithm outside the application's intended set. The CVE-2018-1000531 advisory against inversoft/prime-jwt is the documented case where exactly this confusion shipped in a production library.
CVE-2022-21449: The Psychic Signatures Bug
In April 2022, Neil Madden published a vulnerability in Java's ECDSA signature verification — tracked as CVE-2022-21449 and quickly nicknamed "Psychic Signatures." ECDSA requires both signature components r and s to be non-zero integers in the valid range; a correct implementation rejects a signature where either is zero. The OpenJDK 15 through 18 implementation, after a refactor that moved the validation into a different code path, accepted a signature of (0, 0) as mathematically valid for every public key and every message. Any JWT signed with ES256, ES384, or ES512 on a vulnerable JDK could be forged by submitting a token whose signature segment decoded to zeroes. Oracle patched it in the April 2022 critical patch update; every JWT library on a vulnerable JDK inherited the bug regardless of how carefully the library itself was written. The verification stack is only as safe as the cryptographic primitive underneath it, and runtime CVEs are part of the JWT threat model.
The Audience and Issuer Antipattern
Even libraries that pin the algorithm and reject alg: none ship a quieter bug class: applications that verify the signature but skip the iss and aud checks. The pattern is most visible in multi-tenant SaaS architectures where one auth service issues tokens for several backend services. The auth service signs every token with the same private key; the backend services share the public key. The attacker, who has a legitimate account in tenant A, takes their own valid token and submits it to tenant B's API. The signature verifies, the algorithm matches the allowlist, the expiration is in the future. But tenant B's API never checks that the aud claim matches its own audience, so it accepts the token as authorization for tenant B's data. The Auth0 2024 advisory on multi-tenant audience confusion is one published instance; every internal-platform team has a private version. The fix is mechanical: the same verify call that pins algorithms must also pin issuer and audience, and the values must be the specific URI of the service performing the check, not a wildcard or substring match.
Detection: SAST, SCA, and DAST Carry Different Loads
JWT misuse has a clean fingerprint that static analysis catches because the unsafe API shapes are a finite set: jwt.verify in Node without an algorithms property, jwt.decode in PyJWT without an algorithms keyword argument, parseClaimsJws in jjwt without a require call on the audience and issuer. SAST traces the verification call across method boundaries and flags cases where the options bag does not constrain the algorithm or validate the audience and issuer claims. SCA catches the cases where the application code is correct but the library version is one of the known-vulnerable releases — jsonwebtoken below the algorithm-confusion fix, pyjwt below the version that requires explicit algorithms, the jjwt releases referenced in CVE-2018-1000531, and any JDK in the CVE-2022-21449 range. DAST closes the loop by submitting tampered tokens against the running application: a token with {"alg":"none"}, a token re-signed with HS256 against a known public key, a token whose audience claim points at a different tenant. The cheapest layer is SAST because it runs on the diff that introduced the unsafe verify call.
Prevention Checklist
Six rules close the overwhelming majority of real-world JWT bugs.
- Always pass an explicit
algorithmsallowlist. Never let the library pick the verification algorithm from the token header. The allowlist is one element in most applications. - Validate
issandaudon every verification call. Pin them to the exact URI of the issuing auth server and the consuming service. Wildcards and substring matches do not count. - Prefer asymmetric algorithms (RS256, ES256, EdDSA) for multi-service architectures. Symmetric secrets shared across services compound key-management risk; asymmetric keys keep the signing capability with the auth server alone.
- Rotate signing keys and publish them through a JWKS endpoint with
kidselection. Hardcoded keys ossify; a JWKS-backed verifier picks the key by headerkidand survives rotation without code changes. - Keep token TTLs short and pair them with refresh tokens. Access tokens live for minutes, not days. Revocation of a five-minute token is a wait, not an architectural project.
- Gate vulnerable JWT library and JDK versions in CI with SCA. A pull request that pulls in
jsonwebtokenbelow the patched algorithm-confusion release, or builds against a CVE-2022-21449 JDK, should fail the build.
Where GraphNode Fits
GraphNode SAST traces JWT verification calls across 13+ languages and flags the specific combinations of missing algorithms, missing issuer, and missing audience that leave the forgery and audience-confusion primitives reachable. GraphNode SCA gates vulnerable jsonwebtoken, pyjwt, and jjwt versions at the dependency manifest, including the JDK ranges affected by CVE-2022-21449. JWT misuse is most often classified under A07 Identification and Authentication Failures; both layers surface findings on the diff that introduced the unsafe verify call, with the engineer who wrote it.
Closing
JWT has a documented set of fixes that fit in a half-dozen feature flags. Despite that, alg: none shipped in major libraries in 2015, algorithm confusion shipped in prime-jwt in 2018, Psychic Signatures shipped in OpenJDK in 2022, and the audience-confusion antipattern ships in fresh multi-tenant code today. The pattern persists because the unsafe call is shorter than the safe one and the verify API takes an options bag where missing fields fail open. The teams that stop shipping JWT bugs move detection upstream into the diff, gate the verification call at code review, and keep dependency versions current. Eleven years after McLean's disclosure, that is still the only place the economics work in the defender's favor.
GraphNode SAST and SCA flag unsafe JWT verification and vulnerable library versions across 13+ languages — request a demo.
Request Demo