This Express app is vulnerable four separate ways. There is not one line in it you could point at in review.
const app = express(); // no helmet() → CWE-693
app.use(cors({ origin: true, credentials: true })); // reflected origin → CWE-942
app.use(express.json()); // no size limit → CWE-400
app.post("/transfer", (req, res) => transfer(req.body)); // no CSRF guard → CWE-352
Every one of those is an absence. A diff shows you what changed; it cannot show you the middleware nobody wrote. That is why this class of bug survives review, survives the test suite (the test sends a well-formed request from a trusted origin), and then survives all the way to production — unless you run static analysis that knows what an Express app is supposed to have.
eslint-plugin-express-security reads that same file and fires on the gaps. Below: what each absence costs, which rule catches it, and the two steps that put all 14 rules on your next push. Setup, from npm install to a red pipeline, takes about a minute.
The Hardened Stack series ·
eslint-plugin-node-security→eslint-plugin-express-security(you are here) →eslint-plugin-nestjs-security→eslint-plugin-jwt. Express is the HTTP layer;node-securityis the standard-library floor underneath it.
The vulnerable code:
const app = express();
app.use(express.json());
app.post("/transfer", (req, res) => transfer(req.body));
app.listen(3000);
Why it survived review: A reviewer reads four clean lines, sees a working POST endpoint, and approves. Nothing on screen is wrong. The dangerous part is the lines that aren't there — no helmet() means the response ships without X-Frame-Options (clickjacking), X-Content-Type-Options (MIME-sniffing), Strict-Transport-Security (HTTPS enforcement), or a CSP. You can't review code that was never written, and a test for "did we forget Helmet?" is one nobody writes. The rule files it under CWE-693, Protection Mechanism Failure — the weakness class for a defense that was available and simply not switched on.
The fix:
import helmet from "helmet";
const app = express();
app.use(helmet()); // X-Frame-Options, HSTS, X-Content-Type-Options, CSP — in one line
app.use(express.json());
app.post("/transfer", (req, res) => transfer(req.body));
app.listen(3000);
The vulnerable code:
app.use(cors({ origin: "*", credentials: true })); // fails loudly
app.use(cors({ origin: true, credentials: true })); // fails silently — this is the dangerous one
Why it survived review: The first line is the one everybody expects to be wrong, and browsers do reject it — the spec forbids Access-Control-Allow-Origin: * alongside credentials, so the requests simply break. What happens next is the actual vulnerability. Someone debugs the broken feature, finds that the cors package accepts origin: true, sets it, and the feature works again. origin: true means reflect whatever origin asked — so every site on the internet is now an allowed origin, with the victim's cookies attached. The diff reads - "*" / + true. It looks like a fix. It is the hole.
The fix:
// Option 1: Public API — no cookies, no credentials, wildcard is honest.
// The rule flags this anyway (see below), so make the exception explicit:
// eslint-disable-next-line express-security/no-permissive-cors -- public read-only API, no credentials
app.use(cors({ origin: "*" }));
// Option 2: Credentialed requests — explicit allowlist, never reflection
const ALLOWED_ORIGINS = [
"https://app.yourdomain.com",
"https://admin.yourdomain.com",
];
app.use(
cors({
origin: (origin, cb) => cb(null, ALLOWED_ORIGINS.includes(origin ?? "")),
credentials: true,
}),
);
no-cors-credentials-wildcard catches the wildcard-plus-credentials form; no-permissive-cors catches the reflected form and a bare origin: "*", credentials or not. That second part is why Option 1 carries a disable comment: the browser only rejects * when credentials ride along, but the rule rejects * full stop. It is enforcing policy rather than the spec — "public, no cookies" is how most APIs describe themselves right up until someone adds a session, and at that moment the wildcard becomes the credential leak with nobody having touched the CORS line. If your API really is public, the disable comment is how you say so: one reviewable line naming the assumption, instead of a config that quietly stops being true. Two rules, because the two mistakes arrive at different times — one at design, one during the debugging that follows.
The vulnerable code:
app.post("/transfer", async (req, res) => {
await db.transfers.create(req.body); // req.body is attacker-controlled
res.json({ ok: true });
});
Why it survived review: The line reads like a feature: "accept the transfer payload and persist it." The danger is invisible — no CSRF token check means any site can forge this POST from a victim's browser, using the victim's cookies, and your server will execute it. It also misses a body-size cap, so a 2 GB JSON body is a free denial-of-service. Both look like omissions, not bugs. Both survive a diff. require-csrf-protection carries a declared CVSS of 8.8 — the highest in the plugin.
The fix:
import { doubleCsrf } from "csrf-csrf";
const { doubleCsrfProtection } = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET!,
getSessionIdentifier: (req) => req.session.id,
});
app.use(express.json({ limit: "100kb" })); // bounded body
app.use(doubleCsrfProtection); // forged-request guard
app.post("/transfer", async (req, res) => {
// token already verified by the middleware above
await db.transfers.create(req.body);
res.json({ ok: true });
});
One note before you copy that: the old standard here was csurf, and csurf was deprecated in 2022. csrf-csrf is the maintained double-submit-cookie replacement, and the rule accepts it — it matches any middleware identifier containing csrf, so doubleCsrfProtection satisfies it, as does lusca.csrf() or your own csrfMiddleware. The rule's own fix hint, however, still tells you to install csurf. I wrote that hint. It is on the list.
The vulnerable code:
app.get("/login", (req, res) => {
// ...authenticate...
res.redirect(req.query.returnUrl); // whatever the caller sent, we go there
});
Why it survived review: returnUrl is not a bug-shaped word. It is a convenience feature — "send the user back where they came from after login" — and it appears in the ticket, the design doc, and the acceptance criteria. What it actually is: a link on your domain, wearing your TLS certificate, that lands the user on the attacker's page. That is precisely what open redirects get used for in phishing — the link survives a domain check because the domain really is yours. It is also the kind of omission a coding assistant reproduces without hesitating: across 80 AI-written functions, 65–75% shipped carrying at least one vulnerability, and "reflect the parameter the caller sent" is a shape that satisfies every test you would think to write.
The fix:
// Resolve against an allowlist — never reflect raw input back into a Location header
const ALLOWED = new Set(["/dashboard", "/settings", "/profile"]);
app.get("/login", (req, res) => {
const target = String(req.query.returnUrl ?? "");
res.redirect(ALLOWED.has(target) ? target : "/dashboard");
});
The neighbour this plugin does not catch. The sibling bug on that route is path traversal — res.sendFile(path.join(__dirname, "uploads", req.params.name)), which looks safe right up until name is ../../etc/passwd. The fix is path.basename() before the join. But that is CWE-22, a filesystem weakness rather than an HTTP one, so it belongs to eslint-plugin-node-security (detect-non-literal-fs-filename, no-arbitrary-file-access), not here. A plugin that half-owns a weakness class is worse than one that clearly doesn't.
The six rules above — plus eight more — run before the code reaches review. One install, one config line, 14 CWE-mapped rules on every push.
Step 1 — install:
# npm
npm install --save-dev eslint-plugin-express-security
# yarn
yarn add --dev eslint-plugin-express-security
# pnpm
pnpm add --save-dev eslint-plugin-express-security
# bun
bun add --dev eslint-plugin-express-security
Step 2 — flat config (eslint.config.js), pick one preset:
// `configs` is a NAMED export; the default export is the plugin object.
import { configs } from "eslint-plugin-express-security";
export default [
configs.recommended, // all 14 — criticals at error, the noisier ones at warn
// configs.strict, // all 14, every rule at error
// configs.api, // 5-rule REST hardening set (helmet, CORS, CSRF, cookies, rate limit)
// configs.graphql, // introspection-in-production only
];
Step 3 — run it. Every finding carries the CWE, the OWASP Top 10 category, the CVSS score, and the fix — on the line, not in a portal:
src/routes/transfer.ts
9:1 error 🔒 CWE-352 OWASP:A01-Broken CVSS:8.8 | Route handler for POST request lacks CSRF protection.
Fix: Add CSRF middleware: app.use(csrf()) or use csurf package. Include csrfToken in forms.
That is the whole setup. No Express install, no running server, no build step: the plugin is AST-based, so it reads your app.use(...) chain and route definitions straight out of the source file. It recognises the standard middleware — helmet, express-rate-limit, csurf/csrf-csrf/lusca, cors — on app or on a router, and it fires the moment the pattern is absent.
- 14 rules, each carrying a declared CWE and CVSS, covering headers, CORS, CSRF, cookies, body limits, ReDoS routes, debug endpoints, GraphQL introspection, and open redirects.
- 4 presets:
recommended(all 14 — criticals aterror; the rules with the widest false-positive surface, rate-limiting and CSRF, default towarn) ·strict(all 14 aterror) ·api(5-rule REST hardening set) ·graphql(introspection-in-production only). - Flat config, CommonJS, ESLint
8 || 9 || 10, Node>= 18. AST-based — it lints source; no runtime peers, no Express install needed.
Rule set and preset severities below are v1.3.2, verified against the source on 2026-07-28.
| Rule | Catches | CWE | recommended |
|---|---|---|---|
require-helmet | App missing helmet() security headers | CWE-693 | error |
require-rate-limiting | No rate limiter → brute force / DoS | CWE-770 | warn |
require-csrf-protection | State-changing route, no CSRF | CWE-352 | warn |
require-express-body-parser-limits | Body parser with no size limit | CWE-400 | warn |
no-express-unsafe-regex-route | ReDoS in a route pattern | CWE-1333 | error |
no-permissive-cors | origin: '*' / reflected origin | CWE-942 | error |
no-cors-credentials-wildcard | Wildcard origin + credentials | CWE-942 | error |
no-insecure-cookie-options | Missing Secure/HttpOnly/SameSite | CWE-614 | error |
no-exposed-debug-endpoints | Debug routes reachable in prod | CWE-489 | error |
no-graphql-introspection-production | Introspection enabled in prod | CWE-200 | warn |
no-user-controlled-redirect | res.redirect() of raw user input | CWE-601 | error |
no-missing-cors-check | Origin trusted without validation | CWE-346 | warn |
no-missing-csrf-protection | State change with no CSRF guard | CWE-352 | warn |
no-missing-security-headers | Response missing security headers | CWE-693 | warn |
| Surface | Support |
|---|---|
| Package managers | npm, yarn, pnpm, bun — plain dev dependency |
| Node | >= 18.0.0 |
| ESLint | ^8.0.0 || ^9.0.0 || ^10.0.0, flat config |
| Express | Detects Express 4/5 app.use(...) chains, route definitions, and cors/helmet/csrf/express-rate-limit usage |
| Module system | CommonJS — loads from both eslint.config.js and eslint.config.mjs |
| Runtime peers | None — it lints source AST |
| Oxlint | Loads under Oxlint's JS-plugin runner via the interlace-express-security port |
- Presence, not correctness.
require-helmetproves you calledhelmet(); it can't prove yourcontentSecurityPolicyis tight, or that your rate-limitmaxis sane. It removes the "we forgot entirely" failure mode, not the "our config is weak" one. That is a deliberate trade: "forgot entirely" has by far the higher base rate in real Express codebases, and it is the one a checkable rule can decide without guessing. - Structural checks, not taint tracking. These rules match AST shapes — a missing
app.use, anorigin: true, ares.redirect()whose argument is areq.query/req.bodyaccess. They do not follow a value through an assignment at all:const url = req.query.url; res.redirect(url)is not detected — same function, one line apart. The upside is precision: a structural check either matches or it doesn't, so it rarely invents a finding. The cost is recall — every indirection is invisible, from a local variable to a helper three files away. - It reads the obvious wiring. A bespoke homegrown CSRF layer it doesn't recognise may need an inline disable with a comment explaining why. That is the honest failure mode, and it is a one-line comment rather than a wrong result.
Point configs.recommended at your oldest Express service — the one nobody has touched in a year — and look only at the first finding it reports. My guess is require-helmet, because that one gets decided at project setup and then never revisited. Drop the rule name and roughly how old the service is in the comments; I want to know which absence ages worst.
The "framework hands you the guard, you ship without it" failure shows up at every layer of a Node stack, and each layer has its own rule set:
- The floor underneath: Node.js Security Bugs That Pass Code Review Every Day — crypto,
child_process,fs, and the path traversal from Finding 4. - The framework above: NestJS Security Bugs Your Decorators Hide From Code Review — six-rule sibling set, same absence-of-middleware failure, one abstraction layer up.
- The auth layer: Your AI Assistant Just Re-Added a 10-Year-Old JWT Bypass — one rule blocks the one-line catastrophe behind every Express login.
- The protocol that runs all of them: The 30-Minute Security Audit, and what it found on a real inherited codebase.
- Map it to the standard: I Mapped the OWASP Top 10 to ESLint Rules — including the two categories no linter can honestly claim.
For how this slot compares to everything else competing for it, I benchmarked the field against a hand-labelled ground-truth corpus in Benchmark: 17 ESLint Security Plugins Compared.
The Hardened Stack ·
eslint-plugin-node-security← |eslint-plugin-express-security(current) |eslint-plugin-nestjs-security→ |eslint-plugin-jwt→. If your Express app also touches the filesystem orchild_process, read the node-security floor next — it owns the CWE-22 half of Finding 4.
eslint-plugin-express-security is part of the Interlace ESLint ecosystem. Source on GitHub · Follow: Dev.to/ofri-peretz
