Developed By Ken Key

Node.js Security Best Practices for 2026 (A Practical Checklist)

By Ken Key Aug 25, 2025 6 min read Updated Oct 16, 2026

Node.js Security Best Practices for 2026 (A Practical Checklist)
The short answer

Lock your dependencies with npm ci and audit on every build, validate and sanitize all input, keep secrets in environment variables, set security headers, and never leak stack traces to users. Most breaches come from stale packages and missing validation, not clever attacks.

Part of the complete web development guide.

On this page

Most Node.js security problems I run into aren’t exotic. They’re a missing validation, an old dependency, a secret sitting in a repo, or an error handler leaking a stack trace to the browser. None of that requires a security team to fix. It requires knowing where to look and building a few habits into how you ship code.

This is the checklist I work from when I audit a Node codebase, whether it’s an Express API, a Next.js app, or a background worker. I’ll show the actual code, not just the concept.

Start with dependencies, because that’s where the easy wins are

Your app is only as secure as the packages under it. A typical Node project pulls in hundreds of transitive dependencies, and a single compromised or abandoned one can hand over your server. In 2026 this is still the number one source of real-world incidents I see.

Lock your versions and audit on every build. Don’t run npm audit fix --force blindly in production; it will happily install breaking majors.

# Commit your lockfile. Always.
npm ci

# See what's actually vulnerable, grouped by severity
npm audit --omit=dev

# Check for known malicious / typosquatted packages
npx @socketsecurity/cli scan

A few rules I follow:

  • Pin exact versions in package.json and let the lockfile handle resolution. Caret ranges are how you get a surprise major at 2am.
  • Use npm ci in CI, never npm install. It respects the lockfile and fails loudly if it’s out of sync.
  • Enable Dependabot or Renovate so you’re reviewing bumps weekly instead of once a year.
  • Before adding any package, check the publish date of the latest version, the maintainer count, and whether the source repo is still alive. A package with one maintainer and no commits in three years is a liability.

Validate everything that crosses a trust boundary

Anything from a request body, query string, header, cookie, or webhook is untrusted. The mistake is validating in the route handler and assuming it’s still valid three functions deep. Validate at the edge, then pass typed objects around.

Zod is my default in 2026. It validates and gives you a TypeScript type from the same definition.

import { z } from "zod";

const SignupSchema = z.object({
 email: z.string().email().max(254),
 password: z.string().min(12).max(128),
 age: z.coerce.number().int().min(13).max(120),
});

app.post("/signup", async (req, res) => {
 const parsed = SignupSchema.safeParse(req.body);
 if (!parsed.success) {
 return res.status(400).json({ error: "Invalid input" });
 }
 const { email, password, age } = parsed.data; // typed and safe
 // ...
});

Note the .max() calls. Length limits matter. An unbounded string is a denial-of-service waiting to happen, and regex without limits is how you get catastrophic backtracking. If you’re using a regex on user input, test it against a long hostile string before you ship it.

Parameterize your database queries

SQL injection is not a solved problem, it’s a discipline problem. Template literals in a query string are the bug.

// Wrong. Do not do this.
const rows = await db.query(`SELECT * FROM users WHERE email = '${email}'`);

// Right. Parameterized.
const rows = await db.query("SELECT * FROM users WHERE email = $1", [email]);

Same rule for Mongo. Never pass raw user input into an operator object. A request body of {"password": {"$ne": null}} will happily match every user if you spread it into your filter. Cast and whitelist fields explicitly.

Handle secrets like they’re radioactive

If a secret has ever been committed to git, treat it as burned. Rotate it. Deleting the commit doesn’t help; it’s in the history, in forks, and in whatever scraped the repo within minutes of the push.

  • Keep secrets in environment variables loaded at runtime, or better, a managed secret store (AWS Secrets Manager, Doppler, Vault).
  • Never log them. Scrub tokens and auth headers in your logger before they hit disk.
  • Set .env in .gitignore on day one of every project. I’ve seen this one omission cost more than any code bug.
  • Scan for leaked secrets in CI with gitleaks or trufflehog so a mistake fails the build instead of reaching production.
# Fail the pipeline if a secret shows up in the diff
gitleaks detect --source . --no-git --redact

Get authentication and sessions right

Don’t roll your own crypto. Use a library that’s been beaten on for years: bcrypt, argon2, or scrypt for passwords, and a vetted library for sessions and tokens.

import argon2 from "argon2";

// Hash on signup
const hash = await argon2.hash(password, { type: argon2.argon2id });

// Verify on login
const ok = await argon2.verify(hash, password);

For sessions and cookies, the settings that matter:

  • httpOnly: true so JavaScript can’t read the cookie (blocks XSS token theft).
  • secure: true so it only goes over HTTPS.
  • sameSite: "lax" or "strict" for CSRF protection on top of a token check.
  • Rotate the session ID on login to prevent fixation.

On JWTs: keep them short-lived, always verify the algorithm explicitly (reject alg: none), and don’t stuff sensitive data in the payload. It’s base64, not encrypted. If you need revocation, you need server-side state, and that means a session store, not a stateless token.

Set security headers and lock down CORS

Helmet handles the common headers in one line. It’s not optional.

import helmet from "helmet";

app.use(helmet());
app.use(helmet.contentSecurityPolicy({
 directives: {
 defaultSrc: ["'self'"],
 scriptSrc: ["'self'"],
 objectSrc: ["'none'"],
 upgradeInsecureRequests: [],
 },
}));

CORS is where people get lazy. A wildcard origin: "*" with credentials is a hole. Whitelist the exact origins you trust:

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

Stop leaking information in errors

Stack traces, database errors, and framework banners tell an attacker exactly what you’re running. Catch everything, log the detail server-side, and return a generic message to the client.

app.use((err, req, res, next) => {
 req.log.error({ err, path: req.path }, "unhandled error");
 res.status(500).json({ error: "Something went wrong" });
});

Also turn off X-Powered-By (Helmet does this) and run Node with NODE_ENV=production so dev-only error pages never reach users.

Run the process with the least privilege it needs

If your Node process gets compromised, what can it touch? In a container, the answer should be “almost nothing.”

  • Run as a non-root user inside the container. Add USER node to your Dockerfile.
  • Mount the filesystem read-only where you can, and drop Linux capabilities.
  • Set memory and CPU limits so a runaway request can’t take down the host.
  • Never expose the debug port or admin endpoints to the public internet.

This is the layer people skip because it’s not code. It’s also the layer that limits the blast radius when everything above it fails.

Where this fits into a real project

None of this is glamorous, and that’s the point. Security is a set of defaults you bake into the project template so you never have to think about it again. When I set up custom web development for a client, the security checklist is part of the scaffold, not a phase at the end. It’s cheaper to build it in than to bolt it on after an incident.

If you take one thing from this: automate what you can. Audits, secret scanning, header checks, and dependency bumps in CI catch the boring 90% of problems. The other 10% is judgment, and that’s where experience with real systems pays off.

FAQ

Is Node.js inherently less secure than other backends?

No. The platform is fine. Node apps tend to have more dependencies, and more dependencies mean more surface area, so the discipline around auditing and pinning matters more than it does in a batteries-included framework.

How often should I run a security audit on a Node app?

Continuously, in CI. Dependency scans on every build, secret scanning on every commit, and a deeper manual review of auth, input handling, and infrastructure once or twice a year or whenever you add a new external integration.

Do I need a security tool or can I just follow a checklist?

Both. Tools catch known CVEs and leaked secrets automatically. A checklist catches the logic errors tools can’t see, like a missing authorization check on an endpoint or a query that trusts user input. Tools handle volume, judgment handles the rest.

Have a project like this?

I do custom web development for Long Island businesses. If you need this done right, let’s talk.