DEV Community

Cover image for Your AI Assistant Just Re-Added a 10-Year-Old JWT Bypass. One ESLint Rule Catches It.
Ofri Peretz
Ofri Peretz

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

Your AI Assistant Just Re-Added a 10-Year-Old JWT Bypass. One ESLint Rule Catches It.

// ❌ This is the vulnerability — one line that looks like defensive breadth
jwt.verify(token, secret, { algorithms: ["HS256", "none"] });
Enter fullscreen mode Exit fullscreen mode

One line. That "none" entry tells your verify call to accept a token whose header says "I have no signature — don't check one." Forge the payload, strip the signature to nothing, set alg to none, and you walk in as admin. How easily that lands depends on your library and one more config detail — the per-library breakdown is below — but the hole is that array entry. Everything after it is just how wide it's open.

That's the whole vulnerability, in one line. Here's why it works — and why the library that "fixed" this a decade ago didn't fix your call site.

This bug is 10 years old. Your AI assistant wrote it into your repo last week →

The attack: change one header field

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

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.

Why it survived code review

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

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

The ecosystem numbers

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 — but the classic empty-signature forgery reopens when two things line up: "none" in the algorithms array and a falsy secret at the call site (an unset env var, a per-tenant lookup that returns undefined, anything resolving to ""/null). That second condition isn't a defense you chose — it's a second misconfiguration that ships quietly beside the first. And a correctly-set secret doesn't make "none" safe: a non-empty forged signature just fails a different check first, so the array entry stays a live hole the moment either guard slips
  • jose: doesn't implement "none" as a verifiable algorithm at all — jwtVerify() rejects it, with or without an algorithms option. (jose's other verification failures throw its own branded error classes; this one is a plain TypeError because it's an argument-type check, not a signature check — confirmed via instanceof TypeError against the real 6.x source, not the docs.) Unsecured tokens require the separate, explicitly-named UnsecuredJWT.decode() API, which doesn't verify by design and isn't something you reach for by accident
  • express-jwt: inherits jsonwebtoken's verification path — same opt-in risk when algorithms is 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.

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

The rule: jwt/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
              jwt/no-algorithm-none — Fix: Remove "none" and use RS256, ES256, or other secure algorithms
Enter fullscreen mode Exit fullscreen mode

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.

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. The fix hardened the library defaults. It didn't harden every call site written since then.

The cousin: algorithm confusion

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

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

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


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

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:

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.

Links

⭐ 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

Top comments (0)