// ❌ This is the vulnerability — one line that looks like defensive breadth
jwt.verify(token, secret, { algorithms: ["HS256", "none"] });
One line. That "none" entry. On jsonwebtoken specifically, exploiting it from an empty-signature forgery (the classic alg:none attack — signature segment stripped to nothing) additionally requires secret to be falsy at the call site: an unset environment variable, a per-tenant secret lookup that silently returns undefined for an unrecognized tenant, anything that resolves to "", null, or undefined. That's not a defense you can lean on — it's a second, independent misconfiguration that happens to gate this one specific forgery shape on this one specific library, and it ships quietly right alongside the first. It does not mean a correctly-set secret makes "none" in the array safe: a non-empty forged signature segment fails a different check first, but the array entry itself remains a live hole the moment either guard slips. Libraries that don't guard the falsy-secret case, or that trust the token's own header by default, don't need any of that: "none" in the accepted algorithms is enough on its own.
The title promised the vulnerability in one line. That's it above. Here's why it works.
This bug is 10 years old. Your AI assistant wrote it into your repo last week →
alg: "none" is a JWT header value that means "this token has no signature — don't verify one." It exists for unsecured tokens in controlled environments. It is also a full authentication bypass the moment your verify call accepts it.
A JWT is three base64url parts: header.payload.signature. The attacker takes any valid token, edits the header to claim no algorithm, rewrites the payload to whatever they want, and drops the signature entirely:
# header → {"alg":"none","typ":"JWT"}
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0
# payload → {"sub":"123456","role":"admin"}
.eyJzdWIiOiIxMjM0NTYiLCJyb2xlIjoiYWRtaW4ifQ
# signature → (empty — no key required, no brute force)
.
The attacker sends this token. If "none" is anywhere in the verify call's accepted algorithms — explicitly listed, or implicitly allowed because an unhardened library trusts the token's own header by default — the check reads the alg field from the attacker-controlled header and skips signature verification entirely. It reads "role":"admin" and waves them through.
CWE-347 — Improper Verification of Cryptographic Signature.
The dangerous code is an algorithms array with "none" in it — and it reads like defensive breadth, not a bypass.
A reviewer reading { algorithms: ["HS256", "none"] } left to right sees "HS256" first and reads that as the algorithm being pinned. "none" looks like legacy compatibility bolted onto a safe default, not a second, unconditionally-accepted algorithm. The token under test still verifies, the suite is green, and nobody runs the adversarial case: what if the attacker picks the algorithm?
The quieter variant is the call with no algorithms option at all:
// ❌ Safe on modern jsonwebtoken (≥ 4.x) by default — NOT safe on older
// versions, or on libraries that trust the token's own `alg` header
jwt.verify(token, secret);
Modern jsonwebtoken refuses alg:none here without you asking for it. But a stale transitive pin, an older fork, or one of several smaller libraries that still default to trusting the header will accept it silently — and a reviewer has no way to tell library-hardening-year from the call site alone. jwt.verify(token, secret) isn't "safe" or "vulnerable" on sight; it's "depends on a version number nobody in the PR is looking at."
Three of the most-used JWT libraries in the Node.js ecosystem take three different postures on this — I verified each directly rather than trust the docs:
jsonwebtoken(the most-used, 15M+ weekly downloads): safe by default since 2015 — butalgorithms: ["none"]combined with a falsy secret opts back injose: doesn't implement"none"as a verifiable algorithm at all —jwtVerify()rejects it, with or without analgorithmsoption. (jose's other verification failures throw its own branded error classes; this one is a plainTypeErrorbecause it's an argument-type check, not a signature check — confirmed viainstanceof TypeErroragainst the real 6.x source, not the docs.) Unsecured tokens require the separate, explicitly-namedUnsecuredJWT.decode()API, which doesn't verify by design and isn't something you reach for by accidentexpress-jwt: inheritsjsonwebtoken's verification path — same opt-in risk whenalgorithmsis passed carelessly
The pattern: jose removed "none" from what its verify function can even accept — that door doesn't exist. jsonwebtoken and anything built on it leave the door closed by default but don't remove the lock; a caller can still open it.
// ✅ only the algorithm you sign with — "none" can never match
jwt.verify(token, secret, { algorithms: ["HS256"] });
An explicit, minimal algorithms list is the whole defense. If "none" can't appear in the allowlist, the bypass can't happen. This is true across every library, and it's true whether that list has one entry or several — jwt/no-algorithm-none checks for the presence of "none", not the length of the array, so a legitimate key-rotation window like algorithms: ["RS256", "ES256"] passes clean.
You won't catch a stray "none" in a 200-file codebase by eye. The linter fails the build on it:
npm install --save-dev eslint-plugin-jwt
// eslint.config.mjs — `configs` is a NAMED export (default export is the plugin)
import { configs } from "eslint-plugin-jwt";
export default [configs.recommended];
src/auth.ts
15:3 error 🔒 CWE-347 | Using alg:"none" bypasses signature verification, allowing token forgery | CRITICAL
jwt/no-algorithm-none — Fix: Remove "none" and use RS256, ES256, or other secure algorithms
The rule jwt/no-algorithm-none flags "none" anywhere in an algorithms array — case-insensitively, whether it's the only entry or buried in a list. It also flags the unguarded jwt.verify(token, secret) call with no algorithms option at all, since that leaves algorithm selection to the token itself. It's a static check on the array literal, not a value-flow analysis of your secret — it flags the "none" entry regardless of what the secret argument resolves to at runtime, which is exactly the check you want: whether the secret is falsy today has no bearing on whether it stays that way after the next refactor.
This is the 2015 disclosure (Tim McLean, "Critical vulnerabilities in JSON Web Token libraries") that forced the whole ecosystem to harden. Modern jsonwebtoken won't accept alg:none unless you explicitly allow it — which is exactly the misconfiguration that still ships: someone adds "none" to the algorithms array to make a test pass, or to support a legacy unsigned token, and forgets it's there. The fix hardened the library defaults. It didn't harden every call site written since then.
alg:none has a subtler relative: RS256-to-HS256 key confusion. Verify with no pinned algorithm, hand the library an RS256 public key, and an attacker re-signs a token with that same public key using HS256 — if the library treats the key as an HMAC secret instead of inspecting what it actually is, the forged token passes.
jsonwebtoken (≥ 8.x) closes this one on its own: it infers the key type from the key material and refuses an HS256 token when it's holding an RSA public key, with or without an algorithms option. I verified this directly — it throws invalid algorithm, not a silent accept. Older versions, and other JWT libraries that don't do this inference, are exactly where this attack still lands. Pin it anyway:
jwt.verify(token, publicKey); // ✅ jsonwebtoken ≥ 8.x already rejects this
jwt.verify(token, publicKey, { algorithms: ["RS256"] }); // ✅ pinned — don't rely on library inference to save you on every library, every version
Same root cause as alg:none, same fix: always pass an explicit algorithms list, regardless of whether your current library happens to guard against confusion today.
jwt/no-algorithm-confusion flags it — worth saying plainly what that means at lint time: the rule doesn't know your runtime key types, so it recognizes a "public key" by naming convention and shape (publicKey, .pem, -----BEGIN PUBLIC KEY-----, jwks, and similar). Name your key argument something else and the rule can miss it; that's a real limit of static analysis here, not a bug. jwt/require-algorithm-whitelist covers the gap by flagging any verify() call with no algorithms option at all, independent of what the key looks like.
And the quietest cousin of all is the one that doesn't verify at any algorithm:
// ❌ decode() NEVER checks the signature — it just base64-decodes the payload
const { role } = jwt.decode(token);
if (role === "admin") grantAccess(); // trusting an unsigned, attacker-editable claim
jwt.decode() reads exactly the same forged "role":"admin" payload and returns it without a secret, a key, or a complaint — it's verify() minus the only part that matters. It survives review because the call looks like parsing, not authentication. The rule jwt/no-decode-without-verify flags every decode() call outright, full stop — it doesn't trace whether the result reaches an auth decision, because that's a dataflow question no AST-level rule answers reliably. If you're genuinely only inspecting a header before verifying separately, drop a // @decoded-header-only comment directly above the call — it's a plain leading-comment annotation, no config wiring required, checked against the rule's default trustedAnnotations list — and the rule steps aside; everything else, it wants you to justify.
That one, plus secret strength, claim validation (exp/iss/aud), and jwt/no-algorithm-confusion above, are the other rules in the full plugin — walked end to end in the eslint-plugin-jwt getting-started.
The 2015 disclosure hardened the libraries, but the misconfiguration lives in your call site — and that's exactly the surface an LLM writes for you now. Ask a coding assistant to "verify a JWT and support tokens from our old service" and the obliging completion is a permissive algorithms list that quietly re-includes the legacy unsigned path. The model is pattern-matching on every Stack Overflow answer that ever widened the array to make an error go away; it has no notion that one of those entries is an auth bypass. The library refusing alg:none by default doesn't save you, because the generated code opts back in.
This isn't hypothetical for me — when I had Claude write 60 backend functions, 65–75% shipped with a security vulnerability, and auth/crypto misconfigurations like this one were squarely in the pattern.
And "just use a better model" doesn't save you either. One caveat before the numbers: this measures payload hygiene at sign time, not alg:none at verify time — different JWT mistake, same underlying pattern. When I ran the same JWT prompts across five models, the spread on JWT-specific code was extreme: Gemini 2.5 Flash generated clean token code 0/7 times vulnerable, while Claude Opus — the flagship — leaked sensitive data into the payload 7/7, same prompt, 100% consistency on both sides (the full 700-function leaderboard). The model that aces token generation will still hand you a permissive algorithms list at verify time the instant you ask it to "support our old service" — because that's a config decision, not a code-quality one.
The fix that scales isn't "review the AI's code harder" — human review is the exact step that already waves "none" through. It's the deterministic check on the call shape from the rule above, running on every commit whether a human or a model wrote the diff.
If you're onboarding a new codebase and want to catch this class of vulnerability systematically, the 30-minute security audit protocol covers exactly this kind of static analysis sweep.
# npm
npm install --save-dev eslint-plugin-jwt
# yarn
yarn add -D eslint-plugin-jwt
# pnpm
pnpm add -D eslint-plugin-jwt
# bun
bun add -d eslint-plugin-jwt
# CI — block the PR on a re-introduced "none"
- run: npx eslint . --max-warnings 0
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun |
| Node | >= 18.0.0 |
| ESLint | ^8.0.0 || ^9.0.0 || ^10.0.0, flat config |
| JWT libraries | detects jsonwebtoken and jose call shapes (sign/verify/decode) — reads source |
| Module system | Plugin ships CommonJS; your config can be eslint.config.js or .mjs |
| Oxlint | Loads under Oxlint's JS-plugin runner via the interlace-jwt port, parity-gated in CI |
jwt/no-algorithm-none is one rule in the auth/crypto layer of a larger thesis: the vulnerabilities that survive review are the ones that look like reasonable code. If that pattern is useful, the same lens applied elsewhere:
- The full JWT surface — secret strength,
exp/iss/audvalidation, thejwt.decode()trap, and all the rules: eslint-plugin-jwt getting-started - Full plugin docs and rule reference: eslint.interlace.tools
- One AI bug becomes two — why fixing an AI-suggested vuln by hand tends to spawn the next one: the AI hydra problem
- Interview-grade version —
alg:noneand algorithm confusion are standard questions: the security-engineer interview cheat sheet - Which AI model to (not) trust with auth code — the 700-function, five-model breakdown: we ranked 5 AI models by security
Part of the "AI writes the vulnerability, ESLint catches it" series. Same spine, different CWE each time — Claude vs. Gemini on the same NestJS prompt, the hydra problem, and this one are nodes in it.
- 📦 npm: eslint-plugin-jwt
- 📖 Rule docs: jwt/no-algorithm-none
- 📚 The full JWT walkthrough
- 🔍 30-minute security audit protocol
- 💻 Source on GitHub
⭐ Star on GitHub if you're about to grep your codebase for "none" right now.
Drop the auth bypass your team only found after it shipped in the comments. I collect these.
Does your JWT verify call have an explicit algorithms list right now — go check, I'll wait.
eslint-plugin-jwt is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz