alg: "none" is a JWT header value that means "this token has no
signature — don't verify one." It exists for unsecured tokens. It is also a
full authentication bypass the moment your verify call accepts it:
// ❌ one entry in this list is a forged-token machine
jwt.verify(token, secret, { algorithms: ["HS256", "none"] });
The attack: change one header field
A JWT is three base64url parts: header.payload.signature. The attacker takes
any valid token and 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 private key, no brute force. Because "none" is in your algorithms list,
the verifier skips signature checking, reads "role":"admin", and waves the
attacker through. CWE-347 — Improper Verification of Cryptographic Signature.
It's not theoretical
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.
Why this survives code review
A reviewer reading the diff sees algorithms: ["HS256", "none"] and reads it
left to right: "HS256 — good, they're pinning the algorithm." The "none" at
the end looks like defensive breadth, not a hole — the same instinct that makes
["HS256", "RS256"] look thorough. The token under test still verifies, the
suite is green, and the PR is small. Nobody runs the adversarial case — what if
the attacker picks the algorithm? — because the array reads like a feature,
not a decision. That's the trap: "none" is the one entry whose presence is a
silent OR true on your auth check, and it's syntactically indistinguishable
from a legitimate one.
The fix: pin the algorithms you actually use
// ✅ 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" (and
the keys you don't use) can't appear, the bypass can't happen.
The rule: no-algorithm-none (CWE-347)
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
Fix: Remove "none" and use RS256, ES256, or other secure algorithms
(The ESLint CLI also appends the rule's doc URL to the Fix: line; it's trimmed
here for width.) The rule flags "none" anywhere in an algorithms array —
case-insensitively, and whether it's the only entry or buried in a list like the
one above.
The cousin: algorithm confusion
alg:none has a subtler relative. If you verify with no pinned algorithm and
hand the library your RS256 public key, an attacker re-signs a token with
that public key using HS256 — and the library, treating the public key as an
HMAC secret, accepts it:
jwt.verify(token, publicKey); // ❌ no algorithms list → RS256 verified as HS256
jwt.verify(token, publicKey, { algorithms: ["RS256"] }); // ✅ pinned
Same root cause, same fix: always pass an explicit algorithms list.
no-algorithm-confusion and require-algorithm-whitelist enforce it. Those —
plus secret strength, claim validation (exp/iss/aud), and the
jwt.decode() trap — are the other 12 rules, walked end to end in the
eslint-plugin-jwt getting-started.
The 2025 vector: your AI assistant reintroduces it
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.
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. A generated
diff and a hand-written diff are
indistinguishable to a
reviewer
but identical to a linter — so any reintroduced "none", yours or your
assistant's, fails the build before it reaches review.
Install
# 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
Compatibility
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun |
| Node | >= 18.0.0 |
| ESLint | `^8.0.0 \ |
| JWT libraries | detects {% raw %}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 |
Where this fits
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 the other 12 rules: eslint-plugin-jwt getting-started. - 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.
Links
- 📦 npm: eslint-plugin-jwt
- 📖 Rule docs: no-algorithm-none
- 📚 The full 13-rule JWT walkthrough
- 💻 Source on GitHub
⭐ Star on GitHub if you're about to grep your codebase for "none" right now.
Your turn: what's the auth bypass your team only found after it shipped —
the OR true that read like defensive breadth in the diff? Drop it in the
comments. I collect these.
I'm Ofri Peretz, a security engineering leader and the author of the
Interlace ESLint ecosystem — domain-specific static analysis for security,
reliability, and performance on the Node.js stack. eslint-plugin-jwt is its
JWT/auth layer.
Top comments (0)