DEV Community

Cover image for A Hardcoded sk_live_ Key Passes Code Review. It Won't Pass These 27 ESLint Rules.
Ofri Peretz
Ofri Peretz

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

A Hardcoded sk_live_ Key Passes Code Review. It Won't Pass These 27 ESLint Rules.

A reviewer approves this diff in four seconds:

const stripe = new Stripe("sk_live_51H8xY2eZvKf...");
Enter fullscreen mode Exit fullscreen mode

It passes the type-checker. It passes every unit test. It ships. Three weeks
later it's in a public commit, a security researcher greps your org's repos for
sk_live_, and you're rotating keys at 2am.

Hardcoded secrets are CWE-798. They're not a logic bug a test can catch —
the code works. They're a property of the source text, which is exactly what
a linter is good at. The problem is that the linters most teams run check
style: quotes, semicolons, unused vars. They have nothing to say about
sk_live_.

eslint-plugin-secure-coding is the layer that does. It's 27 rules for
language-level security bugs — hardcoded credentials, unsafe deserialization,
LDAP/XPath/GraphQL/XXE injection, prototype pollution, insecure comparison,
ReDoS — every one pinned to a CWE and carrying a CVSS score and compliance
tags. It's deliberately framework-agnostic: no Express, no Nest, no AWS
specifics (those live in dedicated plugins). Just the mistakes you can make in
plain JavaScript or TypeScript that turn into CVEs.

This is the getting-started guide: how the flagship rule actually decides what
a credential is, the full 27-rule map, install/config across all package
managers, and the exact ESLint/Oxlint versions it runs under.


TL;DR

  • 27 rules, every one carrying a CWE id, a CVSS score, and compliance tags (SOC2 / PCI-DSS / HIPAA / GDPR / …).
  • 4 presets: flagship (the 2 ecosystem-flagship rules), recommended (18 rules), strict (all 27), owasp-top-10 (12 rules mapped to OWASP categories — the mapping is checkable below).
  • Framework-agnostic. "Pure coding security": language-level vulns only. Framework-specific checks (Express, NestJS, Lambda, Postgres, …) live in their own plugins — this one is the base layer underneath them.
  • Flat-config, CommonJS package, ESLint 8 || 9 || 10, Node >= 18. No runtime peer deps — it lints source, not your dependency tree.

The flagship rule: how no-hardcoded-credentials actually decides

A naive secret scanner greps for password and high-entropy strings, then
drowns you in false positives on UUIDs, test fixtures, and base64 blobs. The
reason this rule is usable in CI is that it makes two different decisions
depending on what it's looking at.

1. Registered key formats fire anywhere — no context needed. Some token
shapes are unambiguous: their prefix is owned by a vendor and means exactly one
thing. The rule matches these structurally, wherever they appear:

// ❌ no-hardcoded-credentials (CWE-798) — structural match, fires anywhere
const stripe = new Stripe("sk_live_51H8xY2eZvKf..."); // Stripe secret key
const aws = { accessKeyId: "AKIAIOSFODNN7EXAMPLE" }; // AWS access key
Enter fullscreen mode Exit fullscreen mode

The pattern set covers Stripe (sk_live_/sk_test_/pk_live_/pk_test_/
rk_live_/rk_test_), AWS (AKIA…), and generic 32+ char API-key shapes.
Because sk_test_ and pk_test_ are also registered prefixes, a test key in
a fixture will trip the rule — that's intentional (a leaked test key is
still a leak), and it's why the allowInTests option and per-line disables
exist (see below).

2. Everything else needs a credential-named context. For generic secrets
(a literal assigned to something), firing on every long string would bury you.
So the rule only flags a literal when the surrounding identifier names a
credential and it clears minLength:

// ❌ flagged: identifier names a credential + length >= minLength (default 8)
const apiKey = "a8f5f167f44f4964e6c998dee827110c";
const dbPassword = "hunter2-prod-x9";

// ✅ NOT flagged: same-shaped string, no credential-named context
const requestId = "a8f5f167f44f4964e6c998dee827110c";
const greeting = "welcome to the dashboard";
Enter fullscreen mode Exit fullscreen mode

This identifier-name gate is what keeps the false-positive rate low enough to
run as a CI error instead of a warning everyone ignores.

The fix it wants — pull the value out of source entirely:

// ✅
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
Enter fullscreen mode Exit fullscreen mode

Where the CWE/CVSS/compliance tags come from. The rule declares
CWE-798; the shared CWE map in the engine enriches that into the OWASP
category (A04:2025), a CVSS score (9.8), and the compliance frameworks the
weakness touches (SOC2, PCI-DSS, HIPAA, GDPR, …). So the finding isn't a bare
"bad" — it's an audit-ready line your compliance reviewer can map directly.
Tune it for your repo:

"secure-coding/no-hardcoded-credentials": ["error", {
  allowInTests: true,   // don't flag in *.test.* / __tests__ (default: false)
  minLength: 12,          // raise the generic-secret length floor (default: 8)
  detectDatabaseStrings: true,
  ignorePatterns: ["^EXAMPLE_"], // regexes to skip
}]
Enter fullscreen mode Exit fullscreen mode

For a known-safe fixture, a scoped disable is honest and self-documenting:

// eslint-disable-next-line secure-coding/no-hardcoded-credentials -- documented test fixture
const EXAMPLE_KEY = "pk_test_example";
Enter fullscreen mode Exit fullscreen mode

A second bug a test won't catch: no-unsafe-deserialization

Deserialization of untrusted data (CWE-502) is the quiet RCE. The code
round-trips fine in every test because your tests feed it trusted input:

// ❌ no-unsafe-deserialization (CWE-502) — eval as a deserializer = RCE
const obj = eval("(" + untrustedJson + ")");
Enter fullscreen mode Exit fullscreen mode
// ✅ the rule's own fix
const obj = JSON.parse(untrustedJson); // and validate shape/size before use
Enter fullscreen mode Exit fullscreen mode

The rule flags eval-as-parser and unsafe deserialization sinks, and (notably)
treats AI model/tool output as untrusted too — the fix message reminds you
to validate it via schema and size limits before deserializing.


The full rule set

All 27, grouped, with each rule's declared CWE:

Rule Catches CWE
no-hardcoded-credentials Secrets/keys in source CWE-798
no-hardcoded-session-tokens Session/JWT tokens in source CWE-798
no-sensitive-data-exposure Secrets/PII in logs, responses, errors CWE-532
no-pii-in-logs Email/SSN/card in console logs CWE-359
no-unsafe-deserialization Deserializing untrusted data CWE-502
no-graphql-injection GraphQL injection / DoS CWE-89
no-ldap-injection LDAP injection CWE-90
no-xpath-injection XPath injection CWE-643
no-xxe-injection XML external entity CWE-611
no-format-string-injection Format-string injection CWE-134
no-directive-injection Template directive injection CWE-96
detect-object-injection obj[userKey] / prototype pollution CWE-915
detect-non-literal-regexp RegExp(variable) CWE-400
no-unsafe-regex-construction Regex built from user input CWE-400
no-redos-vulnerable-regex Catastrophic-backtracking regex ReDoS¹
no-missing-authentication Route handler with no auth check CWE-287
require-backend-authorization Missing server-side authz CWE-602
no-privilege-escalation Privilege-escalation patterns CWE-269
detect-weak-password-validation Weak password requirements CWE-521
no-weak-password-recovery Weak password-reset flows CWE-640
no-improper-sanitization Incomplete input sanitization CWE-116
no-improper-type-validation Missing/loose type validation CWE-1287
no-insecure-comparison ==/!= on security values CWE-697
no-unchecked-loop-condition Unbounded loop → DoS CWE-400
no-unlimited-resource-allocation Unbounded allocation → DoS CWE-770
no-electron-security-issues Insecure Electron config CWE-16
require-secure-defaults Insecure-by-default config CWE-1188

¹ no-redos-vulnerable-regex targets the MITRE ReDoS class (CWE-1333); the
others above carry the CWE declared in their rule metadata.


Install

# npm
npm install --save-dev eslint-plugin-secure-coding
# yarn
yarn add --dev eslint-plugin-secure-coding
# pnpm
pnpm add --save-dev eslint-plugin-secure-coding
# bun
bun add --dev eslint-plugin-secure-coding
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-secure-coding";

export default [
  configs.recommended, // 18 rules — the sane default
  // configs.flagship,    // the 2 ecosystem-flagship rules only
  // configs.strict,      // all 27 as errors
  // configs["owasp-top-10"], // the 12 OWASP-mapped rules
];
Enter fullscreen mode Exit fullscreen mode

Tune any rule inline — the preset already registers the secure-coding
namespace, so a later config object can reference it directly:

import { configs } from "eslint-plugin-secure-coding";

export default [
  configs.recommended,
  {
    rules: {
      "secure-coding/no-pii-in-logs": "warn",
      "secure-coding/no-hardcoded-credentials": [
        "error",
        { allowInTests: true },
      ],
    },
  },
];
Enter fullscreen mode Exit fullscreen mode

Run it:

npx eslint .
Enter fullscreen mode Exit fullscreen mode

Each finding carries the CWE, OWASP category, CVSS, severity, compliance tags,
and the fix:

src/payments.ts
  4:24  error  🔒 CWE-798 OWASP:A04-Cryptographic CVSS:9.8 | Hard-coded API key detected | CRITICAL [SOC2,PCI-DSS,HIPAA,GDPR]
               Fix: Use environment variable: process.env.STRIPE_SECRET_KEY or secret management service | https://cwe.mitre.org/data/definitions/798.html
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 \
Module system CommonJS — loads from both {% raw %}eslint.config.js and eslint.config.mjs
Runtime peers None — the rules read source AST; nothing to install at runtime
Oxlint Loads under Oxlint's JS-plugin runner via the interlace-secure-coding port; the flagship rules are wired into the Oxlint config and parity-checked in CI. The full 27-rule set runs on ESLint today.

Honest scope — what "27 rules" means and what it doesn't

  • It's 27 rules, not "89." Earlier copy floated bigger numbers; the published recommended enables 18, strict turns on all 27, and that's the whole plugin. The breadth is in CWE coverage, not rule count.
  • "OWASP coverage" is the owasp-top-10 preset, and it's checkable. That preset wires 12 rules — no-missing-authentication, no-privilege-escalation, no-hardcoded-credentials, no-sensitive-data-exposure, no-graphql-injection, no-xxe-injection, no-xpath-injection, no-ldap-injection, no-weak-password-recovery, no-improper-type-validation, no-insecure-comparison, no-unsafe-deserialization — each mapped to an OWASP category (the 12 are listed right here; the per-rule CWE/OWASP detail lives in the rule docs). No "100% of everything" claim.
  • Static analysis is a floor. These rules prove a dangerous shape isn't in your source. They can't prove your auth logic is correct or your validator is complete — pair them with reviews and runtime controls. They run on every commit and never get tired; that's the value.

Where this sits in the ecosystem

The widely-used generic linters (eslint-plugin-security and friends) overlap
some of this surface but emit a bare rule id. secure-coding adds the depth a
security or compliance reviewer actually needs: a CWE, a CVSS, compliance tags,
and a heuristic (like the two-mode credential detector above) tuned to stay
quiet on fixtures. It's the framework-agnostic base layer of the
Interlace family — the per-framework plugins
(eslint-plugin-pg, -jwt, -express-security, -nestjs-security,
-lambda-security, …) sit on top of it for stack-specific coverage.


Links

⭐ Star on GitHub if this caught something your code review wouldn't.


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. secure-coding is its
framework-agnostic base layer.

ofriperetz.dev · LinkedIn · GitHub

Top comments (0)