Skip to main content
Start your own AI-powered blog — freeGet started →

Node.js Security Essentials: The Checklist I Use in Production

Podcast episode2 voices
7:12
Node.js Security Essentials: The Checklist I Use in Production
Photo by Rahul Mishra on unsplash

Every middleware, header, and validation rule that has actually stopped an attack — from a 7-year production record that includes one very expensive pentest.

The pentest report arrived on a Tuesday. Eleven findings, eight of them high severity. The client — an e-commerce backend serving about 40,000 requests a day — had everything a checklist of this kind is supposed to prevent: an open CORS policy, SQL injection on a search endpoint, a stack trace leaking database internals in a 500 response, and a session token being read from a log file. None of the attacks were exotic. Every single one was preventable with standard, boring middleware.

That week I wrote down every mitigation, in the order you should apply them, and turned it into a checklist. I have run that checklist on every Node.js service I have shipped since — APIs, webhooks, agent backends. This article is that checklist, with the code and the failure modes. Work through it top to bottom and you will have done more than most production systems on the internet.

Step 1: Get the Secrets Out of Your Code

The single highest-value fix is also the most boring: your code should contain zero secrets. No API keys, no database passwords, no JWT secrets, no connection strings. The environment is the only place secrets belong, and .env is gitignored before you write a single line.

bash
# .gitignore
.env
.env.local
.env.production
javascript
// config.js
const required = ["DATABASE_URL", "JWT_SECRET", "STRIPE_SECRET_KEY"];
for (const key of required) {
  if (!process.env[key]) {
    throw new Error(`Missing required environment variable: ${key}`);
  }
}
export const config = {
  databaseUrl: process.env.DATABASE_URL,
  jwtSecret: process.env.JWT_SECRET,
};

Fail fast on startup if a required variable is missing — a service that refuses to boot beats one that boots with undefined as its JWT secret. And treat a leaked secret as a rotation event: the moment one hits git history, rotate it. Git history is permanent; the secret is not.

The pitfall I have seen every time: someone stores the secret in the repo "just for local dev" and forgets it. In 2026, most breaches do not start with a fancy exploit. They start with a .env file committed to a private repo that later goes public, or a secret pasted into a Slack thread that ends up in logs. Secrets are the attack surface nobody patches.

Step 2: Lock Down Your Dependencies

Node's supply chain is its biggest risk. The average service pulls in hundreds of transitive packages, and one compromised dependency can undo every other control on this list. Two habits cover most of the ground.

First, run the audit on every install and on a schedule in CI:

bash
npm audit --audit-level=high

Second, commit your lockfile. package-lock.json should be in the repo and reviewed like code. If a transitive package changes hash without a deliberate upgrade, that is a red flag. And pin exact versions for production-critical packages instead of floating ranges — a ^ range today silently upgrades a minor version tomorrow, and you will not read the diff.

Set a cadence: a monthly npm audit fix review day, and zero tolerance for high or critical vulnerabilities that have a published fix. If you run the service in containers, add an image scanner (Trivy, Grype, or whatever your registry ships) to the pipeline. Dependency hygiene is security, full stop.

Step 3: Set the Security Headers

Headers are cheap insurance. helmet sets a dozen of them correctly in one call:

javascript
import express from "express";
import helmet from "helmet";

const app = express();
app.use(helmet());

By default this sets Content-Security-Policy, X-Content-Type-Options: nosniff, X-Frame-Options, Referrer-Policy, and disables client-side caching of sensitive responses. For a JSON API, tighten the CSP further:

javascript
app.use(
  helmet({
    contentSecurityPolicy: {
      directives: {
        defaultSrc: ["'self'"],
        frameAncestors: ["'self'"],
      },
    },
  })
);

The pitfall: developers remove helmet because "it breaks my image embeds" and never re-add it. If CSP breaks a legitimate asset, tune the directive — do not delete the middleware.

Step 4: Configure CORS as a Deny-by-Default Policy

An open CORS policy — origin: "*" — is how the pentest found its client. Every browser-based attacker can then read your API's responses. CORS must be an explicit allowlist:

javascript
import cors from "cors";

const allowedOrigins = [
  "https://www.example.com",
  "https://admin.example.com",
];

app.use(
  cors({
    origin(origin, callback) {
      if (!origin || allowedOrigins.includes(origin)) {
        return callback(null, true);
      }
      callback(new Error("Not allowed by CORS"));
    },
  })
);

The !origin branch matters: server-to-server calls and curl have no Origin header and must still work. But never reflect the request origin back — that is the vulnerability. And never use * when credentials are in play; browsers ignore * with credentials anyway, so you end up with a broken, unsafe setup that gives a false sense of coverage.

Step 5: Validate Every Input, Always

Your API boundary is the trust boundary. Anything that arrives over the wire — query params, body, headers, cookies — is untrusted until proven otherwise. I use Zod for runtime validation and type inference together:

javascript
import { z } from "zod";

const createOrderSchema = z.object({
  amount: z.number().positive().max(100_000),
  currency: z.string().length(3),
  customerId: z.string().uuid(),
});

app.post("/api/orders", async (req, res) => {
  const parsed = createOrderSchema.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: parsed.error.flatten() });
  }
  // parsed.data is fully typed and validated
});

Validation stops the classic attacks in one move: SQL/NoSQL injection (no string concatenation into queries), prototype pollution (no raw object spread into config), and type confusion. Combine it with parameterised queries — never build SQL by string interpolation:

javascript
// Never this:
await db.query(`SELECT * FROM users WHERE email = '${email}'`);
// Always this:
await db.query("SELECT * FROM users WHERE email = $1", [email]);

The pitfall: validation that only runs on "user input" and skips internal calls. An attacker does not care which layer you call input. Every path to the database validates its arguments.

Step 6: Rate Limit Everything User-Facing

Brute force, credential stuffing, scraping, and OTP bombing all run on the same weakness: no rate limit. You will find the full playbook in my rate limiting article, but the minimum is express-rate-limit on auth routes and a global limiter on the rest:

javascript
import { rateLimit } from "express-rate-limit";

const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 5,
  standardHeaders: "draft-8",
  message: { error: "Too many attempts. Try again later." },
});

app.use("/api/auth/login", authLimiter);

Five attempts per fifteen minutes per IP is not user-hostile; it is what stops a credential-stuffing run in its tracks. For distributed limits across multiple instances, move the store to Redis — more on that in the dedicated article.

Step 7: Handle Sessions and Tokens Correctly

Auth is where most high-severity findings live. The boring, correct setup: short-lived access tokens, httpOnly cookies, and rotation on login.

javascript
res.cookie("session", jwt, {
  httpOnly: true,        // inaccessible to JavaScript — blocks XSS token theft
  secure: true,          // HTTPS only
  sameSite: "lax",       // blocks CSRF on cross-site requests
  maxAge: 15 * 60 * 1000,
  path: "/",
});

If you must use Authorization: Bearer headers instead, accept the trade-offs consciously: tokens in headers are safe from cookie-CSRF but are commonly leaked into logs, proxies, and browser extensions. Whichever you choose, do three things without fail:

  1. Never store raw secrets in localStorage. One XSS and every token is gone.
  2. Verify on every request. Parse, check signature, check expiry, check revocation where the threat model demands it.
  3. Rotate on privilege changes. Password change, login from a new device, or a role upgrade should all issue a fresh token and invalidate the old session.

The pitfall: JWTs with month-long expiry "for convenience". A stolen token is a permanent credential. Keep lifetimes short and make the refresh path do the work.

Step 8: Never Leak Internals in Errors

The client's 500 response that printed a Postgres stack trace taught me this one. Production error responses should be indistinguishable from each other:

javascript
app.use((err, req, res, next) => {
  console.error(err); // full detail goes to your logs only
  res.status(500).json({ error: "Internal server error" });
});

The full error — stack trace, query, file paths — goes to your logging pipeline. The client gets one opaque message. This single middleware closes a whole class of information-disclosure findings, and it costs nothing.

The pitfall that follows immediately: if you then log req.body verbatim, you have just written passwords, tokens, and payment details into your log files — the same logs you forward to third-party tooling, and the same logs that get leaked when anything else goes wrong. Before any logger runs, scrub the fields that should never be stored:

javascript
const SENSITIVE_FIELDS = ["password", "token", "authorization", "card_number", "cvv", "secret"];

function sanitize(obj) {
  const copy = { ...obj };
  for (const field of SENSITIVE_FIELDS) {
    if (field in copy) copy[field] = "[REDACTED]";
  }
  return copy;
}

// Use it everywhere before writing logs.
logger.info({ reqId, body: sanitize(req.body) });

A breach that exposes a clean, redacted log is a reportable incident. A breach that exposes thousands of session tokens because they were sitting in logs is a catastrophe with a class-action attached. Sanitize before you store.

Step 9: Cap the Body, Mind the Payload

Unbounded request bodies are a memory-doS waiting to happen. Express accepts a body size limit in one line, and you should set it lower than you think you need:

javascript
app.use(express.json({ limit: "100kb" }));

A multipart upload endpoint gets its own larger limit, and that is fine — the point is that the default "unlimited" is never acceptable in production. While you are at it, bound the things that indirectly scale with input: array lengths, pagination page sizes, and string lengths. I have seen a limit param of 999999999 take a database and its host down together. Validation limits are security controls, not UX preferences.

Step 10: Authorize Every Route, Not Just Authenticate

Authenticating tells you who the caller is. Authorization tells you what that caller is allowed to do — and the two get confused constantly. The classic finding: a route checks "is there a valid session?" and then lets any logged-in user fetch, update, or delete anyone else's records. That is the broken-access-control class of vulnerability, and it is still one of the most common in the OWASP Top 10.

The fix is a per-route authorization check, layered after authentication:

javascript
function requireRole(...roles) {
  return (req, res, next) => {
    if (!roles.includes(req.user.role)) {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

function requireOwner(param) {
  return (req, res, next) => {
    const resource = req.params[param];
    if (resource.ownerId !== req.user.id && req.user.role !== "admin") {
      return res.status(403).json({ error: "Forbidden" });
    }
    next();
  };
}

app.get("/api/orders/:id", requireRole("customer", "admin"), requireOwner("id"), handler);

The pitfall: authorization logic scattered across route handlers, so one route checks ownership and its neighbour does not. Centralize it in middleware, and write a test per protected route that asserts a 403 for the wrong user. "It was checked somewhere" is not a security control; "it is checked on every route, always" is.

Step 11: Run Least Privilege, Everywhere

The service itself should run as a non-root user with only the capabilities it needs. In a container:

dockerfile
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "src/server.js"]

And the database user your service connects with should have exactly the tables and operations it needs — never a superuser. If the app is compromised, the blast radius is a few tables, not the whole cluster. Least privilege is the difference between "breach" and "incident report".

Step 12: Harden the Transport

HTTPS everywhere, terminated at your proxy, with HSTS telling browsers to never downgrade:

javascript
app.use(helmet({ hsts: { maxAge: 31536000, includeSubDomains: true } }));

Redirect any HTTP request to HTTPS at the proxy layer, and make sure the API refuses to run in production over plain HTTP. TLS is table stakes in 2026; the browser's lock icon is not a feature, it is the baseline.

The Production Checklist

Before I call a Node service done, it passes this list. Copy it.

  • Zero secrets in code — env only, .env gitignored, fail-fast on missing vars
  • npm audit clean (or a dated, reviewed exception) — run in CI
  • Lockfile committed, exact pins for critical deps
  • helmet active, CSP tuned to the actual assets
  • CORS allowlist, deny-by-default, never reflecting origin
  • Zod validation on every route; parameterised queries only
  • Rate limits on auth and public endpoints
  • httpOnly + secure + sameSite cookies; short token lifetimes; rotation on privilege change
  • Opaque production errors; full detail to logs only
  • Runs as non-root; DB user has least privilege
  • HTTPS + HSTS, HTTP redirect
  • Container image scanned in the pipeline

Run this checklist on your current service and you will be ahead of most teams — and several rungs above the service that produced that Tuesday report. Security in Node.js is not exotic. It is a list of boring, correct defaults, applied consistently and never switched off because they are inconvenient.


*Gulshan Yad

Dependency Management and Vulnerability Scanning

Node’s ecosystem is built on a vast network of third‑party packages. A single vulnerable dependency can expose your entire service to exploitation. The first line of defense is a strict dependency policy: lock every dependency with npm ci or yarn install --immutable, and never rely on the latest tag in production. Every pull request should trigger an automated vulnerability scan with tools such as npm audit, Snyk, or Retire.js. If a vulnerability is flagged, the pipeline should fail until a fix is applied—either by updating the package, applying a patch, or replacing it with an alternative.

Beyond automated scans, maintain a curated list of approved packages. When a new library is added, review its maintenance status, community activity, and security track record. If a package is no longer actively maintained, consider forking it and applying critical security fixes yourself. Keep a separate devDependencies section for tooling that only runs in development; these should never make it into the production bundle. Finally, monitor the dependency tree for transitive packages that may introduce hidden risks. Tools that can visualize the full dependency graph help identify deep‑nested vulnerabilities that might otherwise go unnoticed.

A practical checklist for dependency management:

  • Pin all dependencies using lockfiles.
  • Run npm audit or equivalent on every PR.
  • Maintain an approved‑packages whitelist.
  • Remove unused or deprecated libraries.
  • Audit transitive dependencies for critical CVEs.

Runtime Hardening and Process Isolation

Running Node.js inside an isolated environment adds a critical layer of defense. Docker or other container runtimes should be configured to run as a non‑root user, limiting the potential damage from a compromised process. Use Linux user namespaces to map the container’s root to a non‑privileged host UID. Combine this with seccomp profiles that block dangerous system calls such as ptrace, clone with CLONE_NEWUSER, and others that can be abused by attackers.

Node itself offers runtime flags that tighten security. The --no-weak-crypto flag disables legacy algorithms, forcing the use of modern, vetted ciphers. The --trace-uncaught flag prevents silent failures that could lead to insecure defaults. In addition, set NODE_ENV=production to enable built‑in optimizations and disable debugging features. When using clustering or a process manager like PM2, ensure each worker runs with the same non‑privileged user and that the manager itself is not exposed to the public network.

A hardening checklist for runtime:

  • Run containers as non‑root users.
  • Apply seccomp and user‑namespace isolation.
  • Use --no-weak-crypto and --trace-uncaught flags.
  • Enforce NODE_ENV=production.
  • Restrict inter‑process communication to required ports.

Secure Configuration of Node.js and NPM

Secure configuration extends beyond code to the environment in which it runs. TLS settings should enforce TLS 1.2+ and require certificate validation. Disable the rejectUnauthorized flag unless you have a trusted internal CA. Use the tls module’s secureProtocol and ciphers options to specify a hardened cipher suite. For HTTP servers, leverage the helmet middleware to set secure headers such as Content-Security-Policy, X-Frame-Options, and Strict-Transport-Security. Configure CORS policies to restrict origins, methods, and headers, preventing cross‑site request forgery.

Environment variables are a common source of accidental data leakage. Never commit secrets to source control; instead, inject them at runtime via a secrets manager or environment variable injection. Keep a clear separation between development, staging, and production variables, and use a prefix convention (e.g., DEV_, STAGE_, PROD_) to avoid accidental usage. When handling sensitive data in logs, mask or omit it entirely.

Configuration checklist:

  • Enforce TLS 1.2+ and validate peer certificates.
  • Set secure HTTP headers with helmet.
  • Configure strict CORS policies.
  • Store secrets in a dedicated secrets manager.
  • Mask sensitive data in logs.

Logging, Monitoring, and Incident Response

Structured logging is essential for troubleshooting and forensic analysis. Use JSON logs with consistent fields: timestamp, log level, component, message, and a correlation ID that ties related events together. Store logs in a centralized, immutable repository such as a log aggregation service or an ELK stack. Set up real‑time alerting for anomalous patterns—unexpected 500 responses, high error rates, or repeated failed authentication attempts.

An effective incident playbook should include: a clear escalation matrix, predefined containment steps (e.g., throttling traffic, restarting services), and a post‑mortem procedure that captures root causes and remediation actions. Automate the generation of incident tickets from alerts and record all actions taken in a runbook. After an incident, run a debrief to refine the playbook and adjust monitoring thresholds.

Logging best practices:

  • Emit logs in JSON with a correlation ID.
  • Store logs immutably for at least 90 days.
  • Set alert thresholds for error rates.
  • Automate ticket creation from alerts.
  • Conduct post‑mortem reviews.

Testing for Security in CI/CD Pipelines

Security testing must be baked into the CI/CD pipeline, not treated as an afterthought. Static analysis tools such as ESLint with the eslint-plugin-security plugin flag potential unsafe patterns—like eval or new Function. Dynamic testing can involve fuzzing HTTP endpoints with tools like node-fuzz, while integration tests should cover authentication flows and data handling. Include a security gate in the pipeline that blocks merges if any new vulnerabilities are introduced.

Integrate the following steps into every build:

  1. Run linting and static analysis.
  2. Execute unit tests with coverage.
  3. Perform dependency vulnerability scans.
  4. Run dynamic fuzz tests.
  5. Verify that environment variables are not exposed.
  6. Deploy to a staging environment and run end‑to‑end tests.
  7. Promote to production only if all checks pass.

Compliance and Auditing Practices

Many organizations must satisfy regulatory frameworks such as GDPR, PCI‑DSS, or SOC 2. Compliance starts with data classification: identify which data is personal, sensitive, or confidential. Encrypt personal data at rest using AES‑256 and manage keys with a dedicated key‑management service. For PCI‑DSS, ensure that all payment data is tokenized and that logs do not contain raw card numbers.

Audit trails are a core compliance requirement. Log every access to sensitive data, record who performed the action, and enforce strict log‑retention policies that comply with the applicable regulation. Regularly review audit logs for anomalous activity and rotate credentials on a fixed schedule. Finally, maintain documentation of all security controls, incident responses, and remediation actions to demonstrate compliance to auditors.

Compliance checkpoints:

  • Classify and label data appropriately.
  • Encrypt data at rest and in transit.
  • Enforce strict log‑retention and immutable storage.
  • Rotate credentials on a regular cadence.
  • Keep detailed documentation of controls and incidents.

Key Takeaways

  • Pin every dependency with a lockfile and audit before every release
  • Automate vulnerability scans with npm audit, Snyk, or similar tools and act on findings immediately
  • Run your app in a dedicated, non‑privileged container and enforce strict runtime flags
  • Implement structured logging and correlation IDs to simplify incident analysis
  • Integrate security tests into every CI build so failures block deployment

Frequently Asked Questions

How often should I run npm audit on my production code?

Run it on every pull request and before each release. Continuous monitoring in CI ensures that new vulnerabilities are caught early and don’t slip into production.

What is the best way to handle transitive dependencies that are vulnerable?

Use a tool that can report the full dependency tree, pin the specific version that resolves the issue, and, if necessary, submit a patch to the upstream project or replace the package with a maintained fork.

Should I use the --no-weak-crypto flag in production?

Yes. It disables legacy algorithms that are considered weak, forcing the runtime to use only modern, secure ciphers and reducing the attack surface.

How can I ensure my Node process doesn’t accidentally expose sensitive data?

Set NODE_ENV to production, avoid logging stack traces in that environment, and use a secrets manager or environment variables that are injected at runtime rather than committed to source control.

What are the most common mistakes when configuring TLS for a Node server?

Common pitfalls include using outdated TLS versions, missing server certificate verification, and not setting the minimum curve or cipher list. Always enforce TLS 1.2+ and validate peer certificates.

How do I keep my Docker image free of unnecessary packages that could be exploited?

Start from a minimal base image, copy only the compiled artifacts, and run your process as a non‑root user. Remove build‑time dependencies and any shell utilities that aren’t required.

Can I rely on npm’s built‑in scripts for security?

npm scripts run with the same privileges as the user who installed them. Treat them with the same caution as any shell script—avoid trusting untrusted code and consider using npm‑exec or a dedicated wrapper.

What should I include in a Node security incident playbook?

Define clear escalation paths, include automated alerting thresholds, list immediate containment steps, and provide a post‑mortem checklist to capture lessons learned.

Is it necessary to run separate security tests in staging and production?

Staging should mirror production closely, so running the same security tests there helps catch environment‑specific issues before they reach users. Production should have automated monitoring but not duplicate full scans.

How do I handle GDPR data in a Node application?

Encrypt personal data at rest, use tokenization for sensitive fields, keep logs minimal, and provide mechanisms for data subjects to access, rectify, or delete their data.

G
Gulshan Yadav

1 followers

AI systems builder · 7 years in production. RAG, self-hosted infra, agent architecture. 📬 Deep-dives → mrgulshanyadav.substack.com

Comments

Sign in to join the conversation

No comments yet. Be the first to share your thoughts!

More from Gulshan Yadav

Recommended for you