DEV Community

Cover image for The JWT alg:none Attack: Change One Header Field, Forge an Admin Token. One ESLint Rule Blocks It.
Ofri Peretz
Ofri Peretz

Posted on • Edited on • Originally published at ofriperetz.dev

The JWT alg:none Attack: Change One Header Field, Forge an Admin Token. One ESLint Rule Blocks It.

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"] });
Enter fullscreen mode Exit fullscreen mode

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)
.
Enter fullscreen mode Exit fullscreen mode

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"] });
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// eslint.config.mjs — `configs` is a NAMED export (default export is the plugin)
import { configs } from "eslint-plugin-jwt";

export default [configs.recommended];
Enter fullscreen mode Exit fullscreen mode
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
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
# CI — block the PR on a re-introduced "none"
- run: npx eslint . --max-warnings 0
Enter fullscreen mode Exit fullscreen mode

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:

Links

⭐ 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.

ofriperetz.dev · LinkedIn · GitHub

Top comments (0)