JWT Algorithm Negotiation Is a Spec Design Flaw, Not a Library Bug
A production authentication server receives a Bearer token. The header says alg:none. The library verifies it successfully, because the specification said this was valid. No exploit required. No zero-day. Just the spec.
RFC 7519 created algorithm confusion attacks by design. When the specification made the alg header a token-controlled field, it gave attackers the authority to dictate their own verification rules. That flaw was fundamental enough to take five years of production exploits and a separate RFC (8725) to partially address.
JWT Verifies What It Has Not Verified
JWT verification follows this order: decode the header, read the alg field, select the verification function, then validate the signature. The server reads the algorithm from the token before verifying the token. That order of operations is the architectural flaw.
A JWT has the structure base64url(header).base64url(payload).base64url(signature). The header carries {"alg": "RS256", "typ": "JWT"}. The server takes that value, instantiates the matching verifier, and only then checks the signature with that algorithm. Whoever controls the header controls which cryptographic function runs.
RFC 7519 defined alg as a header field, not a server-side configuration. That decision placed algorithm selection on the wrong side of verification.
alg:none Was in the Spec
The none algorithm was not a library mistake. RFC 7519, section 6, defines unsigned JWTs as a valid token type, designed for channels that already ensure integrity through other means. Libraries that accepted alg:none were implementing the specification faithfully.
CVE-2015-9235 documents the impact: auth0/jsonwebtoken before version 4.2.2 accepted tokens with alg:none without signature verification, CVSS 5.5, complete authentication bypass. The fix was an opt-in allowlist in version 4.2.2. Full removal of none came only in 9.0.0, but the spec never changed.
Tim McLean published the initial disclosure in March 2015. The vulnerability affected node-jsonwebtoken, pyjwt, namshi/jose, php-jwt, and jsjwt, in every case through RFC compliance. RFC 8725, published in February 2020 with best practice recommendations, arrived five years after McLean's disclosure and began advising that libraries SHOULD NOT generate or consume JWTs with alg:none unless explicitly requested. The attack is straightforward: modify the header to {"alg": "none"}, remove the signature segment, send any payload.
RFC 7519, section 6, restricted alg:none to channels with transport-guaranteed integrity. Section 8 (Security Considerations) assigned algorithm verification responsibility to implementers, not the specification. The defense argument exists: the spec documented the risks and delegated the decision.
The flaw persists regardless of the authors' original intent. An insecure path that requires active effort to disable is a wrong default. The spec should require explicit configuration to accept alg:none, not require every implementer to reject it manually.
Algorithm Confusion: the Server Verifies With Its Own Public Key
McLean identified a second vector in 2015. A server configured to verify RS256 tokens receives a token declaring alg:HS256. The library, trusting the token field, treats the server's RSA public key as the HMAC secret. The server's RSA public key is public. Any attacker who collected that key can sign a token with HMAC-SHA256 using it as the secret, and the server accepts the forged token.
CVE-2022-21449, called "Psychic Signatures", extended this pattern to the cryptographic implementation level. Java 15 through 18 accepted ECDSA signatures with r and s values equal to zero: the ECDSA verification equation satisfies 0 = 0 always. The flaw was introduced when Oracle rewrote the ECDSA implementation from C++ to Java in Java 15 and removed the non-zero check for r and s. CVSS 7.5 in Oracle's assessment, 10.0 in ForgeRock's assessment for access management context.
Java 17.0.3 and 18.0.1 fixed the problem in April 2022. Before the patch, ECDSA tokens forged with a completely empty signature were accepted on any Java 15-18 system using ECDSA: TLS, JWT, SAML, WebAuthn.
JKU Injection: the Server Fetches the Attacker's Key
The jku header (JWK Set URL) defines a URL the server queries to get public verification keys. The RFC defined it to simplify key rotation in distributed systems. It also created a vector for attacker-controlled JWKS injection.
The attack works in three steps: generate an RSA key pair; host the JWKS with the public key on a controlled domain; sign the token with the private key and insert "jku": "https://attacker.com/.well-known/jwks.json" into the header. The server reads jku from the token, sends a GET to the attacker's URL, retrieves the attacker's public key, and verifies with it. Verification passes.
CVE-2025-30144, affecting Spring Security JOSE, documents this class with CVSS 8.8. The exploitation combines SSRF with signature bypass. Related vectors include the jwk header (inline key in the token header) and the x5u parameter (URL for certificate chain). Defense requires a JWKS URL allowlist fixed in server configuration, never accepted from the token header.
Weak Secrets Are Offline Attacks
Algorithm confusion attacks that force a downgrade from RS256 to HS256 make the HMAC secret the only remaining control. When alg:HS256 comes from the token rather than server configuration, security depends entirely on secret entropy.
Tokens signed with HMAC expose the cryptographic material offline. An attacker with a valid token runs hashcat against it without contacting the server, testing secret candidates until finding the correct one.
Hashcat mode 16500 is JWT-aware: it extracts header.payload, tests HMAC-SHA256 against each candidate. On a RTX 3080, the benchmark is 1,561.3 MH/s: 1.56 billion candidates per second. With rockyou.txt plus the best64 rule, common secrets like password1, secret, jwt, and changeme fall in seconds. An 8-character lowercase alphanumeric secret has 62^8 = 218 trillion combinations, brute-forced in roughly 39 hours with a single RTX 3080.
The impact is not theoretical. A cracked secret means arbitrary payload: change "role": "user" to "role": "admin", sign with the cracked secret, send it. The server confirms the HMAC and elevates the privilege without any interaction with the access control system. Secrets shorter than 12 characters with keyboard patterns are practical targets even without GPU clusters.
Detection: Proxies and API Scanners
Algorithm confusion attacks leave traces in the Authorization header. Detecting them requires inspecting the JWT header in transit, not just monitoring failed authentication events.
Intercept via proxy: decode base64url(header) from each Bearer token and fire an alert on alg:none or unexpected algorithm changes on the same endpoint. A token with alg:none has an empty or missing signature segment: the string ends with . or has only one dot. When an endpoint that was receiving RS256 starts receiving HS256, the transition indicates a confusion attempt.
For JKU injection, monitor outbound HTTP GET requests from the authentication service to external domains. Legitimate auth services do not fetch JWKS from arbitrary domains at request time. The MAGO team tool automates this scan: it intercepts JWT tokens at attack surface endpoints, attempts the five confusion variants (alg:none, RS256 to HS256, JKU injection, kid SQLi, weak secret), and reports vulnerable endpoints by attack type.
RFC 8725, section 3.1, requires that libraries not use algorithms outside the allowlist specified by the caller. The correct configuration is straightforward: pin the algorithm on the server, reject tokens where alg does not match the fixed value, no key fetching from URLs declared in the token.
// Pin allowed algorithms -- never read from token header
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
The question is not whether your library was updated. It is whether your verification logic reads the algorithm from the configuration or from the token.
Top comments (0)