Most production incidents I've watched up close have a depressing shape. Nobody got phished, no zero-day dropped, no unknown attacker found a clever exploit. Someone put a secret in a .env.example two years ago, the rate limit was never added to the login route, the SQL was string-interpolated in one helper, and a dependency had a known CVE for nine months.

A security checklist for Node isn't glamorous because the work isn't glamorous. It's a stack of small, boring safeguards that make the gap between "small mistake" and "public incident" wide enough that you have time to notice.

Here's the list I run through before any new Node service goes to production.

1. Helmet For Default Headers

The single highest-leverage one-liner in Node security:

TypeScript src/app.ts
import helmet from 'helmet';
app.use(helmet());

That gives you Strict-Transport-Security, X-Content-Type-Options, Referrer-Policy, a default Content Security Policy, and a dozen others. You can tune individual headers (CSP almost always needs tuning), but the defaults are good enough that not having helmet is the wrong starting point.

For Fastify, @fastify/helmet is the equivalent. For raw Node, set the headers manually — the list of headers you should set hasn't changed much in five years.

2. Rate Limiting On Hot Endpoints

express-rate-limit and rate-limiter-flexible are the two reliable choices. The trap is putting a global limit on /api and calling it done. Real attacks target specific endpoints — login, password reset, signup, anything with a numeric ID you might enumerate.

TypeScript src/middleware/rateLimit.ts
import rateLimit from 'express-rate-limit';

export const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  standardHeaders: true,
  legacyHeaders: false,
  message: { error: 'too_many_attempts' },
});

Per-IP is the floor. Per-account on login is better, because a botnet rotates IPs but still targets one email at a time. If you're behind Cloudflare or another CDN, make sure req.ip reflects the real client and not the proxy — app.set('trust proxy', 1) if you trust exactly one hop.

3. CORS, Configured Not Copy-Pasted

app.use(cors()) allows every origin. That's the wrong default for a backend API serving credentialed requests. Be explicit:

TypeScript src/app.ts
import cors from 'cors';

app.use(cors({
  origin: ['https://app.example.com', 'https://admin.example.com'],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
}));

If your API is genuinely public, origin: '*' is fine — but then you can't use credentials: true, which means you can't use cookies for auth. The browser enforces this for you. Honor it.

4. Parameterized Queries, Always

The classic SQL injection vector is still alive because string interpolation looks innocent:

TypeScript
// dangerous
db.query(`SELECT * FROM users WHERE email = '${email}'`);

// safe
db.query('SELECT * FROM users WHERE email = $1', [email]);

pg, mysql2, better-sqlite3 — all of them support parameterized queries. Prisma, Drizzle, Kysely use them by default. If you're using an ORM, the dangerous code is db.$queryRawUnsafe() and its siblings; grep for them in code review.

NoSQL is not safe by accident. MongoDB injection happens when you pass req.body.email directly into a query and it turns out to be { $gt: '' }. Validate the shape (back to runtime validation) before it reaches the driver.

Inline diagram showing the layered defenses of a production Node API — helmet headers at the edge, rate limits at the route, validation at the boundary, parameterized queries at the data layer, and dependency scanning in CI — with attack vectors blocked at each layer.
Defense in depth. No single layer catches every attack.

5. Secrets In Env Vars, Not In Files Or Logs

Three rules that prevent most secret leaks:

  • Read secrets from process.env, validated at startup with a schema.
  • Never log req.headers or req.body without a redaction step.
  • Use a secret manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault) for production; .env files are fine for dev.

Pino has built-in redaction:

TypeScript src/logger.ts
import pino from 'pino';

export const logger = pino({
  redact: {
    paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'],
    censor: '[REDACTED]',
  },
});

The most expensive secret leak I've seen was a stack trace that included Authorization: Bearer ... from a request log. The fix took ten minutes. The damage took weeks.

6. Dependency Scanning In CI, Not Manually

npm audit is free, runs in CI, and catches most known CVEs. npm audit --production filters out devDependencies, which is usually what you want for a runtime decision. Snyk and GitHub Dependabot do this continuously; pick one and act on the alerts instead of letting them pile up.

The realistic policy:

  • Critical CVEs in runtime deps — patch within 48 hours.
  • High in runtime deps — patch in the next sprint.
  • Anything in devDeps — when convenient.

A monthly cadence of small dependency PRs beats a quarterly "let's update everything" PR that breaks five things at once.

7. Input Sanitization Where Output Is HTML

If your Node service ever returns HTML — server-rendered pages, email templates, PDF generation — escape user input or use a sanitizer like dompurify (with jsdom) before rendering. Pure JSON APIs are mostly safe from XSS because the consumer is responsible for rendering, but the moment you template HTML, you own the escape story.

This is also where a strict CSP (set via helmet) saves you from your own mistakes. A CSP that disallows inline scripts turns a missed escape from "full account takeover" into "broken layout."

8. The OWASP Top 10 Map

The OWASP Top 10 hasn't changed dramatically in years because the same mistakes keep shipping. The Node-specific translation:

  • Broken access control — see the RBAC layers from the previous article.
  • Cryptographic failures — never roll your own; use bcrypt/argon2 for passwords, node:crypto for everything else.
  • Injection — parameterized queries, schema validation.
  • Insecure design — the architectural reviews you keep postponing.
  • Security misconfiguration — helmet, CORS, env validation.
  • Vulnerable components — npm audit, Dependabot.
  • Identification & auth failures — see the auth article.
  • Software & data integrity — verify webhook signatures, sign artifacts.
  • Logging failures — structured logs, redaction, retention.
  • SSRF — never let user input control outbound URLs without an allowlist.

A One-Sentence Mental Model

Security in Node is not a library you install — it's a habit of treating helmet, rate limits, validation, parameterized queries, redacted logs, and weekly dependency PRs as part of "done," not as a separate project you'll get to later 👊