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

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
- Start with dependencies, because that’s where the easy wins are
- Validate everything that crosses a trust boundary
- Parameterize your database queries
- Handle secrets like they’re radioactive
- Get authentication and sessions right
- Set security headers and lock down CORS
- Stop leaking information in errors
- Run the process with the least privilege it needs
- Where this fits into a real project
- FAQ
- Is Node.js inherently less secure than other backends?
- How often should I run a security audit on a Node app?
- Do I need a security tool or can I just follow a checklist?
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.jsonand let the lockfile handle resolution. Caret ranges are how you get a surprise major at 2am. - Use
npm ciin CI, nevernpm 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
.envin.gitignoreon 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: trueso JavaScript can’t read the cookie (blocks XSS token theft).secure: trueso 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 nodeto 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.


