DEV Community

Cover image for JWT Vulnerabilities Your Code Review Approves Every Day — 13 ESLint Rules Stop Them in CI
Ofri Peretz
Ofri Peretz

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

JWT Vulnerabilities Your Code Review Approves Every Day — 13 ESLint Rules Stop Them in CI

Five JWT vulnerabilities. Every one merged. Every one approved by a human reviewer.

I put all five in a repro project and ran eslint-plugin-jwt over it — v2.2.7, ESLint 9.39.2, Node v24.18.0, measured 2026-07-28. configs.recommended failed the build on four of them. The fifth, the missing audience check, needs configs.strict.

That gap is the most useful thing in this guide, and it is the one thing a preset table won't tell you. Install first, then each vulnerability, why review waved it through, and the rule that stops it.


Install it first (60 seconds)

npm install --save-dev eslint-plugin-jwt
Enter fullscreen mode Exit fullscreen mode
// eslint.config.js — `configs` is a NAMED export
import { configs } from "eslint-plugin-jwt";

export default [
  configs.strict, // all 13 rules — what the five examples below are measured against
];
Enter fullscreen mode Exit fullscreen mode

Run eslint .. If your auth code is older than your conventions, something fires on the first run.

Every rule message below is verbatim v2.2.7 output from that run — file names and line numbers are from my repro project, yours will differ. The only edit is shape: long lines are hard-wrapped to fit the page, and ESLint's trailing rule ID gets its own last line. The message text itself is untouched, including the full OWASP: token and the documentation link that shares the Fix: line. Pinning the version, the linter and the date is the difference between a claim you can reproduce and a claim you can only repeat. The preset trade-off (recommended vs strict vs flagship) is in Which preset you actually need, after the five.


Vulnerability 1: The alg:none attack (CWE-347)

The vulnerable code that shipped:

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

Why it survived review. Nobody writes algorithms: ["none"] on purpose. This line arrived honestly: a dev was debugging tokens from a service that didn't sign its dev tokens, added "none" to unblock themselves locally, and the diff went up with HS256 sitting right next to it. The reviewer sees a real algorithm in the list, sees jwt.verify being called, and approves. The array reads as "we accept HS256, and also this other thing" — not as "we accept forged tokens." A human skims the shape; a CWE-tagged lint rule reads every element.

alg: "none" means "no signature." On jsonwebtoken specifically, this line forges the moment secret is also falsy — an unset env var, a per-tenant lookup that returns undefined for an unrecognized tenant. With a truthy secret, modern jsonwebtoken still throws jwt signature is required; I verified that directly. So on a codebase running jsonwebtoken 9.x with real secrets everywhere, no-algorithm-none is defense-in-depth against a downgrade or a library swap rather than a live bug — still worth CI blocking, because "everywhere" is doing a lot of work in that sentence. Libraries that don't guard the falsy-secret case, or that trust the token's own header by default, don't need the falsy secret at all. It's the root failure behind CVE-2022-23540.

The rule that catches it — no-algorithm-none (CWE-347):

src/vuln1.ts
  4:60  error  🔒 CWE-347 | Including "none" in algorithms array allows
               unsigned tokens | CRITICAL
               Fix: Remove "none" from the algorithms array |
               https://nvd.nist.gov/vuln/detail/CVE-2022-23540
               jwt/no-algorithm-none
Enter fullscreen mode Exit fullscreen mode

The match is case-insensitive — "None", "NONE", "nOnE" all trip it, so string-casing tricks don't get you past this one.

The fix:

// ✅ allow only real algorithms
jwt.verify(token, secret, { algorithms: ["HS256"] });
Enter fullscreen mode Exit fullscreen mode

See the full exploit walkthrough in The JWT algorithm: none Attack — the vulnerability in one line of code.


Vulnerability 2: Algorithm confusion — RS256 public key used as HMAC secret (CWE-347)

The vulnerable code that shipped:

jwt.verify(token, publicKey);
Enter fullscreen mode Exit fullscreen mode

Why it survived review. jwt.verify is being called. The public key is passed. Nothing looks wrong unless you know the algorithm handshake: if no algorithms option is set, an attacker takes your RS256 public key (it's public — they already have it), signs a token with it using HS256, and — on a library that treats the key as an HMAC secret without checking its type — the forged token is accepted. The attack is invisible at the call site.

Modern jsonwebtoken closes this on its own, but it took two fixes years apart and the first is narrower than it looks. I diffed the published source between 4.2.1 and 4.2.2: 4.2.1 already defaulted to the RS/ES family for BEGIN CERTIFICATE and BEGIN PUBLIC KEY (PKCS#8) keys. All 4.2.2 added, for CVE-2015-9235 (GHSA-c7hr-j4mj-j2w6), was one more branch: BEGIN RSA PUBLIC KEY — the older PKCS#1 format — stops falling through to HMAC. That single PEM format was the whole gap. Both fixes are string matching, not key inspection; the robust version, createPublicKey(...).asymmetricKeyType, only landed in 9.0.0 (CVE-2022-23541). Anything older, plus any library that never added an equivalent check, is where this still lands — and a reviewer reading the call site has no way to know which fix the running version has. Pin the algorithm anyway. Don't let a decade of layered library patches be the only thing between this call and a forged token.

Which rule fires here is worth being exact about, because I guessed wrong the first time. This call site trips require-algorithm-whitelist (CWE-757, "no explicit algorithms list") — not no-algorithm-confusion. The confusion rule needs two things at once: an argument that looks like a public key (publicKey, .pem, jwks, a PEM header) and an explicit HS256/HS384/HS512 in the options. With no options at all it has nothing to compare and stays quiet. The broader rule is the one that saves you.

The rule that catches it — require-algorithm-whitelist (CWE-757):

src/vuln2.ts
  4:10  error  🔒 CWE-757 | JWT verification without explicit algorithms
               trusts the token header | HIGH
               Fix: Add algorithms: ["RS256"] or algorithms: ["ES256"] to
               options | https://tools.ietf.org/html/rfc8725
               jwt/require-algorithm-whitelist
Enter fullscreen mode Exit fullscreen mode

The fix:

// ✅ pin the algorithm so the key type can't be repurposed
jwt.verify(token, publicKey, { algorithms: ["RS256"] });
Enter fullscreen mode Exit fullscreen mode

This is also the exact pattern AI assistants produce by default. I ran a quick check — 20 generations across Gemini 3 Pro and Flash, 10 each, prompt: "Write a Node.js function called verifyToken that takes a token string and verifies it." All 20 came back with a bare jwt.verify(token, secret) and no algorithms option. require-algorithm-whitelist fired on every one. Treat that as a spot check, not a benchmark: 20 generations, one prompt, no held-out corpus. The model reproduces the median of its training data, and the median predates the jsonwebtoken 9.0.0 fix.

The two numbers behind it are measured properly: Gemini 2.5 Pro generated unsafe database code 96% of the time in the database domain of a 700-function benchmark, and 65-75% of 80 Claude-written functions shipped with a security vulnerability. Auth config sits squarely inside that pattern, not outside it.


Vulnerability 3: Hardcoded weak secret (CWE-798 + CWE-326)

The vulnerable code that shipped:

const token = jwt.sign({ userId: 123 }, "supersecret123", {
  algorithm: "HS256",
});
Enter fullscreen mode Exit fullscreen mode

Why it survived review. "supersecret123" is clearly a placeholder — or so the reviewer assumes. It reads as "this will be replaced with an env var before prod." Sometimes it was replaced. Sometimes it shipped exactly as written, went into git history, and stayed there after the env var was added. The string in history is still a credential.

The rules that catch it — three fire on this one call:

src/vuln3.ts
  4:10  error  🔒 CWE-613 OWASP:A07-Authentication CVSS:5.4 | JWT without
               expiration is valid forever, increasing exposure window |
               MEDIUM
               Fix: Add expiresIn: "1h" or exp claim to payload |
               https://tools.ietf.org/html/rfc8725
               jwt/require-expiration
  4:36  error  🔒 CWE-326 OWASP:A04-Cryptographic CVSS:5.9 | JWT secret must
               be at least 32 characters (256 bits) for HS256 | HIGH
               Fix: Generate a secret with:
               crypto.randomBytes(32).toString("hex") |
               https://tools.ietf.org/html/rfc8725
               jwt/no-weak-secret
  4:36  error  🔒 CWE-798 OWASP:A04-Cryptographic CVSS:9.8 | Secret key
               hardcoded in source code can be extracted from repositories |
               HIGH [SOC2,PCI-DSS,HIPAA,GDPR]
               Fix: Use process.env.JWT_SECRET or secure secret management |
               https://cwe.mitre.org/data/definitions/798.html
               jwt/no-hardcoded-secret
Enter fullscreen mode Exit fullscreen mode

Read that last message again: CVSS:9.8 and the word HIGH, printed by the same rule on the same line. On the CVSS 3.1 bands, 9.0–10.0 is Critical. Our own severity word is one band low. That is not drift I found in someone else's tool — I wrote the rule, and auditing 203 of our security rules turned up 33 with the same mismatch: 16%, this plugin included. The score comes from CWE metadata; the word next to it doesn't, and nothing was checking the two agreed. Gate CI on cvss >= 7.0, never on the severity string — advice I can give with confidence, having been the one to break it.

The fix:

// ✅ secret from environment — length is now your responsibility, not the rule's
const token = jwt.sign({ userId: 123 }, process.env.JWT_SECRET!, {
  algorithm: "HS256",
  expiresIn: "1h",
});
Enter fullscreen mode Exit fullscreen mode

The entropy argument ("it's a high-entropy string, it's fine") doesn't survive contact with a public git history. I made that case in No Hardcoded Credentials: Entropy Isn't Enough. The rule is deliberately blunt: a JWT secret as a string literal is a finding, full stop.


Vulnerability 4: jwt.decode() feeding an auth decision (CWE-345)

The vulnerable code that shipped:

// "quick helper" in a middleware
const payload = jwt.decode(token) as { role: string };
if (payload.role === "admin") {
  /* ... */
}
Enter fullscreen mode Exit fullscreen mode

Why it survived review. jwt.decode() and jwt.verify() have nearly identical call sites. Someone needed the userId for a logging helper, reached for decode() because it doesn't need the secret, and returned the payload. In every test — with a token the test itself just minted — it works. The reviewer sees a function that reads a JWT and returns a user, backed by green tests, and approves. What no test exercises: decode() happily parses { "role": "admin" } with a garbage signature and hands it back. The gap between "parsed the token" and "verified the token" is exactly one method name and zero failing tests.

The rule that catches it — no-decode-without-verify (CWE-345):

src/middleware.ts
  4:19  error  🔒 CWE-345 OWASP:A08-Software CVSS:7.5 | jwt.decode() returns
               payload without verifying signature - data can be forged | HIGH
               Fix: Use jwt.verify(token, secret) instead of
               jwt.decode(token) |
               https://owasp.org/API-Security/0xa7-security-misconfiguration/
               jwt/no-decode-without-verify
Enter fullscreen mode Exit fullscreen mode

no-algorithm-none reports on this line too, which surprised me until I read what it says: treating a decoded payload as authenticated is algorithm: "none", spelled differently. Two rules, one bug — and the one I didn't expect names the failure better than the one I did.

The one deliberate false positive, and how to actually silence it. Reading a token's kid header to choose which key to verify with — the standard JWKS key-selection pattern — trips this rule even though a real verify() call is two lines below. The rule can't see it coming, so it reports: a false positive it accepts on purpose rather than guess at intent. The escape hatch is an annotation, and it has to be opted into — no preset turns it on:

// eslint.config.js
rules: {
  "jwt/no-decode-without-verify": [
    "error",
    { trustedAnnotations: ["@decoded-header-only"] },
  ],
}
Enter fullscreen mode Exit fullscreen mode
// @decoded-header-only
const { kid } = jwt.decode(token, { complete: true })!.header;
Enter fullscreen mode Exit fullscreen mode

The comment has to sit before the code, and that word is doing the work. I tested four placements against v2.2.7. Three suppress: a // comment on the line above, a /* */ block above, and JSDoc on the enclosing function — the check walks up from the decode() call through every ancestor, reading the comments before each one, and stops only after it has inspected the containing function. The one placement that silently keeps reporting is a trailing comment on the same line, because a comment after the code is never a comment before any node on that chain. Put it on the line above; the docblock works too.

The fix:

// ✅ verify, don't decode, when the result is used for auth
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
  algorithms: ["HS256"],
});
Enter fullscreen mode Exit fullscreen mode

Vulnerability 5: no audience check — one token, two services (CWE-287)

The vulnerable code that shipped:

// service-b/src/auth.ts
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
  algorithms: ["HS256"],
});
Enter fullscreen mode Exit fullscreen mode

Why it survived review. Service A mints a token for its own users. Service B shares the same signing secret — same team, same infra, same env var — and verifies with it. The call looks complete: algorithm pinned, signature checked, no red flags. Nobody asks the question that matters: was this token minted for me? I verified this directly — a token Service A issued with no audience claim passes Service B's verify call cleanly, because nothing in the call site checks which service the token was scoped to. Same secret, same algorithm, wrong service — and it's accepted.

The rule that catches it — require-audience-validation (CWE-287), and it is not in recommended:

service-b/src/auth.ts
  4:53  error  🔒 CWE-287 OWASP:A07-Authentication CVSS:9.8 | JWT verification
               without audience validation accepts tokens intended for other
               services | MEDIUM
               Fix: Add audience option:
               { audience: "https://api.example.com" } |
               https://tools.ietf.org/html/rfc8725
               jwt/require-audience-validation
Enter fullscreen mode Exit fullscreen mode

Two things in that block. First, the severity word is one whole band below the score again — CVSS 9.8 printed MEDIUM, the same score-versus-label drift as Vulnerability 3, and the loudest single example in the 203-rule audit. Second, and more practical: this rule ships in configs.strict, not configs.recommended. Install the plugin, take the default preset, and this is the one vulnerability of the five that still merges.

The fix:

// ✅ scope the token to the service verifying it
const payload = jwt.verify(token, process.env.JWT_SECRET!, {
  algorithms: ["HS256"],
  audience: "service-b",
});
Enter fullscreen mode Exit fullscreen mode

require-issuer-validation is the mirror case — same failure, opposite direction: nothing checks who minted the token, so a token from a lower-trust issuer (a staging environment, a partner API) verifies cleanly against a production secret it was never supposed to reach.


Which preset you actually need

Four presets ship. The difference between them isn't strictness in the abstract — it's which of the five above still merges. All four run against the same repro project, v2.2.7 / ESLint 9.39.2 / Node v24.18.0, 2026-07-28:

Preset Rules on Blocks, of the five above Reach for it when
flagship 1 2 — vulnerabilities 1 and 4 One shared org-wide gate: a single rule, no argument about its budget
legacy 3 (2 error, 1 warn) 2 errors, plus 3 as a warning Migrating an old codebase — keep forgery blocking, turn the rest down
recommended 9 (8 error, 1 warn) 4 — everything except 5 A new service whose tokens don't cross a trust boundary yet
strict 13 5 Anything minting or verifying tokens across services

The four rules recommended leaves off are all claim validation: require-issuer-validation, require-audience-validation, require-issued-at, require-max-age. They're off by default for a defensible reason — they fire on every verify() call in a codebase that hasn't adopted iss/aud yet, and a plugin whose first run lights up every auth file in the repo gets uninstalled before lunch. They're also, precisely, Vulnerability 5. If your tokens cross a service boundary, pay the first-run noise and take strict:

export default [
  configs.strict, // all 13 — the only preset that blocks all five above
  // configs.recommended, // 9 rules — quieter first run, no iss/aud/iat/maxAge enforcement
  // configs.flagship,    // 1 rule (no-algorithm-none) — the ecosystem-wide flagship tier
  // configs.legacy,      // 3 rules — migration mode
];
Enter fullscreen mode Exit fullscreen mode

One useful accident in that table: flagship catches Vulnerability 4 as well as Vulnerability 1, because no-algorithm-none also reports on jwt.decode(). One rule, two of the five — which is why it's the rule that goes in the gate when you only get one.


The full rule set

All 13, each pinned to a CWE identifier. The last column is the one to read before you pick a preset — it's read straight off the v2.2.7 config objects:

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

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

Passing all 13 rules is a lint-time property, not an architecture review — it's a different check than "should this shared secret exist at all." One HMAC secret shared across services means any service holding it can mint tokens the others will accept; audience narrows who a token is for, it doesn't stop whoever holds the secret from minting one. If Service A and Service B are separate trust boundaries, not just separate deployments of the same trust, the actual fix is asymmetric — RS256/ES256 with Service A holding the private key and every verifier holding only the public one.


Compatibility

Verified against eslint-plugin-jwt v2.2.7 (latest on npm, 2026-07-28):

Surface Support
Package managers npm, yarn, pnpm, bun
Node >= 18.0.0
ESLint `^8.0.0 \
JWT libraries detects {% raw %}jsonwebtoken's sign/verify/decode and jose's SignJWT/jwtVerify — reads source, no library pin. jose's decodeJwt isn't in the decode-detection set yet, so no-decode-without-verify currently only fires on jsonwebtoken-shaped decode calls
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 sees — and doesn't

  • 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 correct shape (explicit algorithms + iss/aud/exp/maxAge); the values are yours to set correctly.
  • Two rules worth knowing the limits of. no-algorithm-confusion has no runtime key knowledge — it matches "this looks like a public key" by name and shape (publicKey, .pem, a PEM header, jwks) and only reports when an explicit HS* sits next to it. Rename the argument, or drop the algorithms list, and it misses: a false negative by design, which is why the broader require-algorithm-whitelist is what fired on Vulnerability 2. no-decode-without-verify errs the other way and flags every decode() call, whether or not the payload reaches an auth check, because it does no taint tracking — following a value from source to sink isn't something an AST-level rule answers reliably. Two rules, opposite error directions, one plugin: that trade is the precision-versus-recall conversation in miniature, and the reason presets exist at all.
  • What's out of scope. Key-selection attacks — a kid header used unsanitized in a file path or DB lookup, or a jku/x5u header pointed at an attacker-controlled JWKS endpoint — aren't rules in this set. They're a real, current class of JWT incident, and static analysis on the verify() call site doesn't see them; that's a key-retrieval-function problem, not a call-shape problem. Scoped out here, not solved.

This fits into the broader static-analysis onboarding protocol I use when auditing codebases: The 30-Minute Security Audit: I Ran It on 140 Gemini-Written Functions. 102 Shipped Vulnerable..


Where this sits in the ecosystem

eslint-plugin-jwt is the dedicated JWT layer — signature bypass, algorithm confusion, secret strength, claim validation — each finding tagged with a CWE. Generic security plugins don't cover this layer; it's one of the gaps I found benchmarking 17 of them. In OWASP Top 10 terms it lands in A02 (Cryptographic Failures) and A07 (Identification and Authentication Failures) — categories, not severities, which is exactly why the CWE and the CVSS score do the ranking work here; the full rule-to-category mapping is here. It pairs with eslint-plugin-express-security and eslint-plugin-nestjs-security as the auth member of the Hardened Stack series.

The Hardened Stack · eslint-plugin-express-security | eslint-plugin-jwt (current) | eslint-plugin-nestjs-security


Links

Run configs.strict against the oldest service you have that verifies a token — the one written before anyone on the team had opinions about aud. Something will fire. That is the good outcome, not the bad one: the pattern was already in production, and now it has a CWE, a score, and a line number. Fix the errors, read the warnings, ship.

Then read The JWT algorithm: none Attack next — Vulnerability 1 end to end, from a forged token to an accepted session. Same bug, one level deeper.

Which JWT incident changed how you review auth code — and being honest about it, would a linter have caught that one? I'd like to know how many of these are configuration and how many are architecture. My guess is it splits about even.

📦 npm install --save-dev eslint-plugin-jwt — 13 rules, one dev dependency.


eslint-plugin-jwt is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz · X @ofriperetzdev

Top comments (0)