DEV Community

Cover image for jsonwebtoken Will Verify a Token Signed With algorithm: none. These 13 ESLint Rules Stop It.
Ofri Peretz
Ofri Peretz

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

jsonwebtoken Will Verify a Token Signed With algorithm: none. These 13 ESLint Rules Stop It.

This one line is a full authentication bypass:

jwt.verify(token, secret, { algorithms: ["HS256", "none"] });
Enter fullscreen mode Exit fullscreen mode

alg: "none" is a JWT header value that means "this token has no signature."
Allow it in your verify call and an attacker forges any token they like —
{ "sub": "admin" }, no signature, accepted. It's CWE-347 (Improper
Verification of Cryptographic Signature), it's the bug behind CVE-2022-23540,
and eslint-plugin-jwt fails your build on it:

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

That's the flagship rule. There are 12 more — for algorithm confusion,
weak/hardcoded secrets, and every missing claim check that turns a "verified"
token into a forgeable one — each pinned to a CWE.


TL;DR

  • 13 rules, each carrying a CWE id, covering the JWT auth surface: signature bypass, algorithm confusion, secret strength, and claim validation.
  • 5 presets: flagship (the alg:none rule), recommended (the standard set), strict / all (everything), and legacy (relaxed for older jsonwebtoken setups).
  • Flat-config, CommonJS, ESLint 8 || 9 || 10, Node >= 18. AST-based — it reads your jwt.sign/jwt.verify/jwt.decode calls; no runtime peer.

The flagship: no-algorithm-none (CWE-347)

// ❌ no-algorithm-none — "none" disables signature verification
jwt.verify(token, secret, { algorithms: ["HS256", "none"] });
Enter fullscreen mode Exit fullscreen mode
// ✅ allow only real algorithms
jwt.verify(token, secret, { algorithms: ["HS256"] });
Enter fullscreen mode Exit fullscreen mode

The rule flags "none" anywhere in the algorithms array. Its fix message is
explicit: remove "none" and use RS256, ES256, or another secure
algorithm.
This is the canonical JWT attack and the reason "we use JWTs" is
not the same as "our auth is safe."


Algorithm confusion: no-algorithm-confusion (CWE-347)

The subtler cousin. If your server verifies with the algorithm list left open,
an attacker takes your public RS256 key (it's public!), signs a token with
it using HS256, and your server — treating the public key as an HMAC secret
— accepts it:

// ❌ no-algorithm-confusion — no pinned algorithm lets RS256 be verified as HS256
jwt.verify(token, publicKey);
Enter fullscreen mode Exit fullscreen mode
// ✅ pin the algorithm so the key type can't be repurposed
jwt.verify(token, publicKey, { algorithms: ["RS256"] });
Enter fullscreen mode Exit fullscreen mode

require-algorithm-whitelist (CWE-757) enforces that you always pass an
explicit algorithms list — the precondition that closes both attacks above.


The claim checks that make "verified" mean something

A signature-valid token can still be expired, replayed, or minted for a
different service. These rules require the checks:

  • require-expiration (CWE-613) — sign with expiresIn; a token with no exp is valid forever.
  • require-issuer-validation / require-audience-validation (CWE-287) — verify iss/aud so a token minted for another service isn't accepted by yours.
  • require-max-age, require-issued-at, no-timestamp-manipulation (CWE-294) — bound token age and guard the iat/clock-skew handling against replay.
  • no-decode-without-verify (CWE-345) — jwt.decode() does not verify the signature; using its output for auth decisions trusts an unverified token.

Secrets

  • no-weak-secret (CWE-326) — an HS256 secret short enough to brute-force.
  • no-hardcoded-secret (CWE-798) — the signing secret as a string literal.
  • no-sensitive-payload (CWE-359) — PII in the payload (a JWT is base64, not encrypted — anyone can read it).

The full rule set

All 13, with each rule's declared CWE:

Rule Catches CWE
no-algorithm-none alg:none signature bypass CWE-347
no-algorithm-confusion RS256↔HS256 key confusion CWE-347
no-decode-without-verify jwt.decode() used for auth CWE-345
require-algorithm-whitelist no explicit algorithms list CWE-757
no-weak-secret brute-forceable HS256 secret CWE-326
no-hardcoded-secret signing secret in source CWE-798
no-sensitive-payload PII in the (readable) payload CWE-359
require-expiration missing exp / expiresIn CWE-613
require-issuer-validation missing iss check CWE-287
require-audience-validation missing aud check CWE-287
require-issued-at missing iat CWE-294
require-max-age no maxAge on verify CWE-294
no-timestamp-manipulation clock-skew / replay exposure CWE-294

The complete secure pattern

What passes all 13:

// Signing — pinned algorithm, bounded lifetime, scoped to iss/aud
const token = jwt.sign({ userId: 123 }, process.env.JWT_SECRET, {
  algorithm: "HS256",
  expiresIn: "1h",
  issuer: "your-app",
  audience: "your-api",
});

// Verifying — explicit algorithms, validated claims, bounded age
const payload = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ["HS256"],
  issuer: "your-app",
  audience: "your-api",
  maxAge: "1h",
});
Enter fullscreen mode Exit fullscreen mode

Install

# npm
npm install --save-dev eslint-plugin-jwt
# yarn
yarn add --dev eslint-plugin-jwt
# pnpm
pnpm add --save-dev eslint-plugin-jwt
# bun
bun add --dev eslint-plugin-jwt
Enter fullscreen mode Exit fullscreen mode

Flat config (eslint.config.js):

// `configs` is a NAMED export; the default export is the plugin object.
import { configs } from "eslint-plugin-jwt";

export default [
  configs.recommended, // the standard set
  // configs.strict,    // everything
  // configs.flagship,  // just no-algorithm-none
  // configs.legacy,    // relaxed for older jsonwebtoken setups
];
Enter fullscreen mode Exit fullscreen mode

Compatibility

Surface Support
Package managers npm, yarn, pnpm, bun — plain dev dependency
Node >= 18.0.0
ESLint `^8.0.0 \
JWT libraries detects {% raw %}jsonwebtoken and jose call shapes (sign/verify/decode) — reads source, no library pin
Module system CommonJS — loads from both eslint.config.js and eslint.config.mjs
Oxlint Loads under Oxlint's JS-plugin runner via the interlace-jwt port, with ESLint↔Oxlint parity gated in CI. The full 13-rule set runs on ESLint today.

What it does — and doesn't — see

  • Source patterns, not runtime tokens. It flags algorithms: ["none"], a missing expiresIn, a jwt.decode() feeding an auth check. It can't validate a token at runtime or prove your secret's entropy — it enforces that the call is configured safely.
  • Pin the algorithm, then validate claims. The rules push you toward the one correct shape (explicit algorithms + iss/aud/exp/maxAge); the values are yours to set correctly.

For a worked exploit of the headline bug, see the companion piece
The JWT algorithm: none Attack — the vulnerability in one line of code.


Where this sits in the ecosystem

Generic linters don't know what jwt.verify or an algorithms array is.
eslint-plugin-jwt is the dedicated JWT layer — signature bypass, algorithm
confusion, secret strength, claim validation — each finding tagged with a CWE.
It's the auth member of the Interlace family,
complementary to the generic set and the other server-side plugins
(eslint-plugin-express-security, eslint-plugin-nestjs-security, …).


Links

⭐ Star on GitHub if your verify call has ever trusted algorithms: ["none"].


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)