JWT ALG=NONE IS NOT DEAD

alg=none is the bug everyone learned about in 2015 and assumed was solved. It isn't solved — it's just been forgotten. AI-generated auth code reintroduces it about as often as it gets reviewed. Here's what it actually looks like in 2026.

The scenario referenced below runs on gapbench.vibe-eval.com — a public security benchmark we operate for scanner calibration. The client engagement that originally surfaced this pattern is anonymized; the gapbench scenario is the reproducible equivalent.

I want to talk about a bug from 2015

In 2015 someone published a write-up showing that some JWT libraries treated the alg: none header as “this token is fine, no need to verify the signature.” Users panicked, libraries patched, the security press wrote about it for a week, and then everyone agreed the bug was dead and moved on.

I have personally seen this bug ship in production three times in the last six months. Twice on AI-generated apps. Once on a hand-written Express service where someone had asked Cursor to “add JWT auth, similar to how the docs show it.”

It’s not that the bug came back. It’s that the conditions that produce it never went away. You have a model that has read every JWT tutorial ever written, including the ones from 2014 that say algorithms: ['HS256', 'none'] is a reasonable thing to type. You have a developer who is not auditing the line where the AI wrote that. You have a CI pipeline that doesn’t know what a JWT library is, much less how to test it for this specific class of bug.

What you get is a server that, when handed an unsigned JWT with "alg": "none", says “yep, looks good to me,” reads the claims, and logs the attacker in as whoever the claims say they are.

The shape of it

There’s a verification function. It looks roughly like this:

import jwt from 'jsonwebtoken'

export function verifyToken(token: string) {
  return jwt.verify(token, SECRET, {
    algorithms: ['HS256', 'none']  // <-- this line
  })
}

Or like this:

import { jwtVerify } from 'jose'

export async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, SECRET)
  return payload
  // No `algorithms` option — older `jose` versions infer from the token header.
  // Token header says "none" — verification skipped.
}

Or — and this is the one that hurts to look at — it looks like this:

const decoded = jwt.decode(token)         // decode does NOT verify
const user = await getUser(decoded.sub)    // we just trust the claim
if (user) {
  jwt.verify(token, SECRET)                // verify happens, but
  return user                              // we already used the unverified claim
}

That third one is not strictly the alg=none bug — the verify call is fine. It’s a different shape of the same family: trust the JWT before verifying it. We see this pattern often enough in AI-generated middleware that it deserves its own line item.

The kid traversal variant is more exotic and you can usually only get to it if the AI generator has been told about a “key directory” pattern. The header includes "kid": "default", the verifier reads keys/default.pem from disk, attacker sends "kid": "../../../dev/null", the verifier reads /dev/null, gets an empty string, and signs the token’s HMAC with an empty string. Attacker also signs their forged token with an empty string. Verification passes. We have a working scenario for this on gapbench at /site/jwt-alg-confusion/ — go look.

Why does the AI keep producing this

A few overlapping reasons.

First, the training corpus is huge and old. Stack Overflow answers from 2014, 2015, 2016 are in there with high upvote counts. Those answers say things like “just put ’none’ in the algorithms list for testing.” The model learned that pattern alongside the modern, safe ones, and there’s no signal saying “the second one is the broken one.”

Second, JWT libraries often have multiple ways to do verification, and not all of them are equally safe. jwt.decode() and jwt.verify() look identical at a glance. The AI uses decode because it’s shorter. The AI uses verify and gets it right. It depends on which example it pattern-matched on this time.

Third, the bug is invisible until it’s exploited. Your tests pass. Your auth flow works for legitimate users. The CI pipeline says everything is green. The only way this bug shows up is if someone deliberately crafts an attack token, and your test suite isn’t going to do that on its own.

Fourth — and this is the one I find most annoying — there’s almost no friction in the development environment. The library doesn’t print a warning. The TypeScript types don’t complain. The runtime doesn’t refuse the malformed token. Everything cooperates with the bug.

A specific incident

Anonymized version of one engagement. The product was a fintech B2B tool — connected to bank accounts, moved money. Auth was JWT-based, with a homegrown verification middleware. The middleware called jwt.decode to read the userId claim, looked up the user, then called jwt.verify later in the request, and threw if verification failed.

The bug was the order. Some request paths used the userId from the unverified decode and then called downstream services with it before the verify step ran. For most endpoints that didn’t matter — the verify ran and the request rejected before anything user-visible happened. For one endpoint — a webhook handler that hit a downstream queue — the queue insert happened in a setImmediate callback that ran before the verify-throw could halt the request. The downstream worker processed the queue entry under the unverified user’s identity.

We found this by chaining a forged JWT (alg=none) into the queue endpoint and observing the queued action complete. Total exploit primitive: send an unsigned JWT, the action runs as anyone in the system. The fintech company shut the endpoint down, did a 14-day audit of queue history, found one suspicious entry from two months earlier that was probably a researcher and not malicious. The fix was three lines: move the verify before the queue insert, refuse any request that called decode without a subsequent verify, add a CI test that probed the endpoint with a malformed token.

The middleware had been written by Cursor.

What “verify” actually has to mean

The most common subtle-bug variant. Two functions in jsonwebtoken:

  • jwt.decode(token) — parses but does not verify. Returns the payload regardless of signature validity.
  • jwt.verify(token, secret, options) — verifies and returns the payload. Throws if invalid.

They look identical at a glance. They produce the same payload for valid tokens. They are completely different security objects. AI generators use whichever one made the prior line of code work. Sometimes that’s decode. Sometimes the result of decode gets used to look up something before verify runs. Sometimes verify runs but its return value gets discarded and the original decode payload gets used.

// WRONG: verify happens but its result is ignored
const decoded = jwt.decode(token)  // <- payload from here is unverified
try {
  jwt.verify(token, SECRET)  // <- correctly throws on invalid, but
} catch (e) { return res.status(401).end() }
return await handle(decoded)  // <- still uses the unverified payload!

// RIGHT: only the verified payload is used
const verified = jwt.verify(token, SECRET, { algorithms: ['HS256'] })
return await handle(verified)

The wrong version looks fine. There’s a try/catch. The verify is called. The 401 path exists. But the payload that the handler receives is the original decode output, which was never verified. If the attacker submits a token whose decode produces the right shape and whose verify throws (because the signature is bad), the catch path runs and we 401 — fine. But if the attacker can submit a token where decode produces a useful payload AND verify succeeds because of some other bug (alg=none, kid traversal, swapped public/private key), the bug payload gets handled.

Cross-stack notes

The same pattern repeats across languages.

  • Python (PyJWT). jwt.decode(token, options={"verify_signature": False}) is explicit. AI generators set verify_signature: False “for testing” and forget to flip it back. The algorithms parameter is required in modern PyJWT but the older key=None alg=none variant still works in older versions.
  • Java (jjwt, jose4j). The default Jwts.parserBuilder() requires a key; the unsafe pattern is Jwts.parser().parseClaimsJws(token) without setSigningKey() followed by parseClaimsJwt(...) (note: Jwt, not Jws) which parses unsigned tokens. AI codegen has been observed to mix the two.
  • Go (golang-jwt). jwt.Parse(token, keyFunc) calls the keyFunc with the parsed token’s algorithm. If the keyFunc returns a key without checking token.Method, an attacker can submit alg: HS256 with the public RSA key as the secret and the token verifies. This is the classic “alg confusion” attack and Go’s default API makes it easier to write than the safe version.
  • .NET (Microsoft.IdentityModel.Tokens). TokenValidationParameters controls everything. The unsafe defaults are ValidateIssuerSigningKey = false, ValidateLifetime = false, etc. AI codegen sometimes copies these from internal-development examples.

The general rule: any JWT API that lets you choose between “verify” and “don’t verify” is a footgun. The safer libraries (modern jose, modern PyJWT, modern .NET) require you to declare the expected algorithm explicitly and refuse to verify against an unspecified one. If your library doesn’t, treat the call site as suspect.

How we catch it

The detection is mechanical and there are no false positives if you do it right. We hit your auth endpoint with a known-good token (you provide one in setup, or we mint one if your sign-up flow is open). We then send a series of malformed variants:

  • The same token with "alg": "none" and an empty signature
  • The same token with "alg": "none" and a junk signature
  • The same token with the kid set to several traversal payloads
  • The same token signed with the public RSA key as if it were an HMAC secret (the alg-confusion classic)
  • A token with the algorithm changed from HS256 to RS256, signed with a key the attacker controls

If any of those produce an authenticated session, that’s the finding. We confirm by running the same suite against the clean control at gapbench.vibe-eval.com/site/ref-jwt/ — if any payload triggers there, we treat it as a false positive and tune the rule. That’s the whole point of having ref-jwt. It’s the boundary between “real bug” and “noisy detection.”

A static scanner can flag the algorithms: ['HS256', 'none'] line in source if it knows to look. None of them, in our testing, catch the third pattern (decode-then-trust-then-verify) because the lines look fine in isolation — it’s the order that’s wrong. Runtime testing against the deployed endpoint is the only way to catch all three variants reliably.

Fixing it

The fixes are all small. The discipline is the hard part.

For the algorithms: ['none'] case: take 'none' out of the list. If you don’t know which algorithm you should be using, the answer is almost certainly the one your token issuer is signing with — usually HS256 or RS256. Pick one. Pin it. Test that the wrong algorithm gets rejected.

For the missing-algorithms case: provide the option explicitly. Every modern JWT library lets you pin the expected algorithm. Pin it. The same goes for libraries that infer the algorithm from the token — that is a vulnerability, not a feature.

For the decode-then-trust case: don’t call decode to read claims. Always call verify and use the verified claims. If you need to inspect a token before verifying — for example, to look up the right key — do that inspection in a way that doesn’t trust the result. The pattern is: peek at kid to find the key, then verify with that key, then read the claims from the verified payload, never from the original.

For the kid-traversal case: don’t read the kid as a file path or database key without sanitizing it. Whitelist the allowed kid values. If you only have one signing key, hardcode it and ignore the kid entirely.

And as a meta-fix: write a JWT auth test suite that includes the malformed variants. We publish ours, anonymized, as part of the vibe-code-scanner test corpus. You can run it once a release.

CWE / OWASP

  • CWE-347 — Improper Verification of Cryptographic Signature
  • CWE-287 — Improper Authentication
  • CWE-295 — Improper Certificate Validation (for the kid-traversal variant where the trust chain breaks)
  • OWASP API Security Top 10 — API2:2023 Broken Authentication
  • OWASP Top 10 — A07:2021 Identification and Authentication Failures

Reproduce it yourself

Live scenarios on the gapbench benchmark:

Run VibeEval against the vulnerable URLs and the JWT-confusion finding should fire. Run it against ref-jwt and the same suite should report nothing. If your own app’s scan results look more like the first than the second, fix it before someone else finds it for you.

RUN IT YOURSELF

Each scenario below is live on the public benchmark. The commands are copy-paste ready. Outputs may evolve as we tune the scenarios; the bug stays.

Mint a real token
TOKEN=$(curl -s -X POST https://gapbench.vibe-eval.com/site/jwt-alg-confusion/api/login -d '{"username":"alice","password":"password"}' -H "content-type: application/json" | jq -r .token) && echo $TOKEN
expected Returns a signed JWT. Save it for the alg=none probe below.
Forge alg=none token
HEADER=$(echo -n '{"alg":"none","typ":"JWT"}' | base64) && PAYLOAD=$(echo -n '{"sub":"admin","role":"admin"}' | base64) && curl -s -H "authorization: Bearer ${HEADER}.${PAYLOAD}." https://gapbench.vibe-eval.com/site/jwt-alg-confusion/api/me
expected Returns the admin profile. The unsigned token was accepted because alg=none verification was skipped.
Clean control
HEADER=$(echo -n '{"alg":"none","typ":"JWT"}' | base64) && PAYLOAD=$(echo -n '{"sub":"admin","role":"admin"}' | base64) && curl -s -o /dev/null -w "%{http_code}" -H "authorization: Bearer ${HEADER}.${PAYLOAD}." https://gapbench.vibe-eval.com/site/ref-jwt/api/me
expected Returns 401. Same payload, same shape, but verification is pinned to HS256 and the unsigned token is rejected.

COMMON QUESTIONS

01
What is the JWT alg=none attack?
JWTs include an algorithm field in their header — 'alg' — that says how the token is signed. The standard once permitted 'alg: none' to mean an unsigned token, intended for testing. Some libraries still accept it by default. If your server reads the alg from the token and trusts it, an attacker sends a token with alg=none and an empty signature, the server skips signature verification, and the token's claims are trusted. The attacker controls the claims. Game over.
Q&A
02
What is the kid path-traversal variant?
JWTs can also include a 'kid' field — key ID — telling the server which signing key to use. Some implementations read the kid as a file path or a database lookup key. An attacker crafts a kid like '../../../dev/null' or '../public/static/known-file' and forces the server to verify against a file the attacker can predict, then signs the token with that file. Same outcome as alg=none — attacker-controlled claims accepted as authentic.
Q&A
03
Doesn't every modern JWT library refuse alg=none by default?
Most do, in their current versions. The catch: the AI generator doesn't always pick the current version, and the AI generator definitely doesn't always pick the secure-by-default API. We see jsonwebtoken called with options like {algorithms: ['HS256', 'none']} because the model pattern-matched on a Stack Overflow answer from 2017. We see jose called without algorithms specified at all, which on some versions opens the door. The library is not always the problem; the way the AI calls the library is.
Q&A
04
How do I know if my JWT verification is vulnerable?
The cleanest test is the same one an attacker would run. Take a real token from your app, base64-decode the header, change the alg to 'none', recompute the base64, and send it with no signature. If you get back a valid session, you have the bug. If your server returns 401 or invalid-token, you don't. The VibeEval scanner runs this test plus the kid-traversal variants automatically against any auth endpoint that accepts JWTs.
Q&A
05
What if I use a JWT library that 'cannot' do this — am I still at risk?
You can still introduce the bug at the application layer. A common pattern: the AI writes middleware that decodes the JWT, reads claims like user_id, and looks up the user — but only verifies the signature in a separate code path that doesn't always run, or runs after the user lookup, or runs but ignores its return value. The library is fine. The integration is broken.
Q&A
06
Where can I see this happen on a real URL?
https://gapbench.vibe-eval.com/site/jwt-alg-confusion/ runs both the alg=none and kid-traversal patterns against a deliberately vulnerable verifier. The clean control is at https://gapbench.vibe-eval.com/site/ref-jwt/ — same JWT shape, verification done correctly, neither variant works.
Q&A
07
What CWE and OWASP categories does this map to?
CWE-347 (Improper Verification of Cryptographic Signature), CWE-287 (Improper Authentication). OWASP API Security Top 10 #2 (Broken Authentication). OWASP Top 10 A07:2021 (Identification and Authentication Failures).
Q&A

TEST YOUR AUTH ENDPOINT

We send the malformed JWTs an attacker would send and tell you which ones your server accepted. 60 seconds, no setup.

RUN THE SCAN