Node.js Security in 2026: A Practical Checklist

Node.js security in 2026 comes down to locking dependencies with package-lock.json, running npm ci and npm audit --omit=dev in CI, disabling install scripts by default, and validating every input at the boundary. Patch Node itself, keep secrets out of code, and never trust a package just because it's popular.
Part of the complete web development guide.
On this page
- 1. Audit your dependencies, then stop trusting them
- 2. Get secrets out of your code, permanently
- 3. Validate everything, with a schema, at the boundary
- 4. Set real HTTP headers with helmet
- 5. Never eval, never deserialize blindly
- 6. Rate limit and lock down auth endpoints
- 7. Patch Node and your packages on a schedule
- 8. Use Node’s permission model
- 9. Run with least privilege
- Where to go from here
- FAQ
- Is npm audit enough to catch supply-chain attacks?
- Does the Node permission model replace Docker or a sandbox?
- How often should I update dependencies?
Most Node.js security advice you’ll find online is either too vague (“always validate input!”) or written for a world where you control every byte that hits your server. Neither is true. Here’s the checklist I actually run against my own projects and client work, in the order I’d tackle it. It’s not exhaustive, but every item below has caught a real problem for me or someone I know.
1. Audit your dependencies, then stop trusting them
Run npm audit and npm audit --production. They are not the same. The first flags dev dependencies you’ll never ship. The second is what actually matters in production. If the output is a wall of noise, filter it: npm audit --omit=dev --audit-level=high gets you to the ones worth fixing today.
But npm audit only knows about published CVEs. The scarier stuff is a package that does something weird with no CVE at all. So:
- Commit your
package-lock.json. Always. A lockfile pins exact versions and integrity hashes. Without it, someone runningnpm installon a fresh machine gets whatever the registry serves that day. - Use
npm ciin CI, notnpm install. It installs exactly what’s in the lockfile and fails if the lockfile andpackage.jsondisagree. - Turn on
npm config set ignore-scripts truefor your own machine if you’re paranoid. Postinstall scripts are how a lot of supply-chain attacks execute. If a package needs one, run it deliberately. - Check who publishes your dependencies. A package with one maintainer, no repo, and a version bump from 1.0.2 to 1.0.3 that adds a new transitive dep is a red flag. Tools like Socket or Snyk can flag this automatically, but a five-minute look at npmjs.com works too.
- Pin your direct dependencies to caret ranges you actually tested, not
*. And review lockfile diffs in PRs. A one-line lockfile change that swaps a package’s resolved URL to a different host is exactly the kind of thing you want a human to see.
2. Get secrets out of your code, permanently
If a secret has ever been committed, assume it’s compromised. Rotating is faster than scrubbing git history, and scrubbing never catches the fork someone made three years ago.
Use environment variables. Not a config.js with a hardcoded string. Not a .env file committed to the repo. In production, use your host’s secret manager (AWS Secrets Manager, Fly secrets, Railway variables, whatever you’re on). Locally, use a .env file that’s in .gitignore and load it with Node’s built-in --env-file flag so you don’t even need dotenv:
node --env-file=.env server.js
Then validate at startup. The worst failure mode is a missing DATABASE_URL that silently defaults to localhost and starts writing to the wrong database. Fail loud and early:
const required = ['DATABASE_URL', 'SESSION_SECRET', 'STRIPE_KEY'];
for (const key of required) {
if (!process.env[key]) {
throw new Error(`Missing required env var: ${key}`);
}
}
Two more things. Never log process.env. And add a .env.example with placeholder keys so new devs know what’s needed without you leaking real values.
3. Validate everything, with a schema, at the boundary
“Sanitize input” is not a strategy. You can’t sanitize your way out of every payload. What you can do is define exactly what shape of data your endpoint accepts, reject anything that doesn’t match, and never trust types after that. Zod is my default for this in 2026. It’s fast, it’s typed, and the parse step is your boundary.
import { z } from 'zod';
const SignupSchema = z.object({
email: z.string().email().max(254),
password: z.string().min(12).max(128),
age: z.number().int().min(13).max(120),
});
app.post('/signup', (req, res) => {
const result = SignupSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ errors: result.error.issues });
}
const { email, password, age } = result.data;
// now email is a string, password is a string, age is a number
});
Notice safeParse, not parse. With parse you get a thrown exception you have to catch. With safeParse you handle the failure branch explicitly, which is what you want in a request handler.
Validate on the way in and on the way out. If you’re returning user data, run it through a response schema so you never accidentally leak a hashed password or an internal field. Validation at the boundary is how you stop a bad shape from propagating through ten layers of your app.
For anything hitting a database, use parameterized queries. Always. If you’re on an ORM, know when it falls back to raw SQL and check those spots. Same for anything that shells out: never pass user input to exec. Use execFile with an argument array, or better, don’t shell out at all.
4. Set real HTTP headers with helmet
Browsers do a lot of security work for you if you tell them to. helmet sets sensible defaults in one line:
import helmet from 'helmet';
app.use(helmet());
That gets you Content-Security-Policy (a strict default), X-Content-Type-Options: nosniff, Strict-Transport-Security, and a few others. Two things to tune:
- If you’re behind a reverse proxy (nginx, Cloudflare, a load balancer), set
app.set('trust proxy', 1)so Express reads the real client IP fromX-Forwarded-For. Without this, rate limiters and logs see your proxy’s IP for everyone. - Loosen the CSP only as much as you have to. Start strict, add the specific
script-srcorconnect-srcorigins you need, and avoidunsafe-inlineunless you truly have no other option.
Also set app.disable('x-powered-by'). Small thing, but there’s no reason to advertise the framework.
5. Never eval, never deserialize blindly
eval, new Function, and vm.runInThisContext on user input are remote code execution waiting to happen. There is almost always a better option. If you’re parsing config, use JSON. If you’re doing math from a string, use a real parser. If you’re templating, use a template engine that escapes by default.
Same story with deserialization. node-serialize had a famous RCE. Any library that turns a string into a live object with methods is a liability. Stick to JSON for data interchange. If you’re on Node’s built-in v8.deserialize or node:v8 serialization, only ever feed it data you serialized yourself.
And watch prototype pollution. If you’re merging user-supplied objects with something like a naive recursive merge, a payload with __proto__ or constructor.prototype keys can poison Object.prototype for your whole process. Zod strips unknown keys by default, which helps. So does Object.create(null) for maps you build from user input.
6. Rate limit and lock down auth endpoints
Any endpoint that takes a password, sends an email, or costs you money needs a rate limit. express-rate-limit is the standard for Express:
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
});
app.post('/login', loginLimiter, loginHandler);
Ten attempts per fifteen minutes per IP is a reasonable starting point for login. Tune it based on your users, but don’t skip it. For a real defense, layer in per-account lockouts or exponential backoff after N failures, because IP-based limits fall apart behind shared NAT or a botnet.
Other auth hygiene that’s easy to forget:
- Hash passwords with bcrypt, scrypt, or argon2. Never SHA-256 alone. Never MD5.
- Use timing-safe comparison (
crypto.timingSafeEqual) when comparing tokens. - Set cookies with
httpOnly,secure, andsameSite: 'lax'or'strict'. - Rate limit password reset requests too. Otherwise you’ve built a spam cannon.
7. Patch Node and your packages on a schedule
This is the boring one and the one people skip. Node releases security patches regularly, and running an end-of-life version means you’re not getting them. In 2026, you should be on a supported LTS. Check node --version in every environment and make sure they match.
For dependencies, automate it. Dependabot or Renovate can open a PR the day a patch drops. Small, frequent updates are far less painful than a quarterly “we need to jump three major versions” fire drill. I’ve watched teams put this off for a year and then spend two weeks untangling it. Not worth it.
Also patch your base Docker image. An outdated node:18 image from 2023 has known CVEs regardless of what your package.json says.
8. Use Node’s permission model
Node 20 introduced --experimental-permission, and it’s matured since. The idea: by default, your process can’t read files, write files, spawn child processes, or load native addons unless you explicitly allow it. If your app gets compromised, the attacker’s reach is dramatically smaller.
node --permission \
--allow-fs-read=/app/config \
--allow-fs-read=/app/public \
--allow-fs-write=/app/tmp \
server.js
That process can read from those two directories, write to one, and do nothing else. No reading /etc/passwd, no spawning shells, no loading a random .node binary. Combine it with --allow-child-process only if you truly need it, and --allow-worker if you use worker threads.
It takes some fiddling to get the allowlist right, and the first time you run it you’ll probably hit a permission error from a library you forgot about. Worth the hour. It’s the closest thing Node has to a real sandbox without containerizing.
9. Run with least privilege
Don’t run your Node process as root. Ever. In a container, create a non-root user and switch to it:
FROM node:22-alpine
WORKDIR /app
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]
The official Node images already have a node user. Use it. On a bare VM, create a dedicated user with no sudo, no shell login, and only the file permissions it needs. If your app only reads from /app, it doesn’t need write access to /var.
Same principle for database users. Your app’s DB user should have SELECT, INSERT, UPDATE, DELETE on the tables it uses. Not DROP. Not superuser. If someone gets SQL injection, least privilege is the difference between “they read some rows” and “they dropped your database.”
Where to go from here
You don’t need to do all nine this week. If you do three, do these: get secrets out of your code, add Zod validation at every request boundary, and turn on helmet. Those three catch the majority of what actually bites people in production.
If you’re building something on Node and want a second set of eyes on the security side, this is the kind of work I do. I’ve been writing Node and building custom software engineering solutions for years, and I also handle custom web development for clients who’d rather not find out about a vulnerability from a customer email. Either way, the checklist above is a good place to start on your own.
FAQ
Is npm audit enough to catch supply-chain attacks?
No. It catches known CVEs in published packages. It won’t catch a malicious package that hasn’t been reported yet, a compromised maintainer account, or a postinstall script that does something sketchy. Pair it with lockfiles, npm ci, review of lockfile diffs, and a tool that watches for suspicious package behavior. npm audit is a floor, not a ceiling.
Does the Node permission model replace Docker or a sandbox?
Not entirely, but it’s a real layer. It restricts what your process can do at the runtime level, which is different from what a container restricts at the OS level. They complement each other. If you’re already containerizing, adding --permission narrows the blast radius further. If you’re not containerizing, it’s the best sandboxing you’ll get without one.
How often should I update dependencies?
Continuously, via automation. Let Dependabot or Renovate open PRs, and merge small patch updates within a week. Major version bumps deserve a real look, but small ones shouldn’t sit for months. The teams that get burned are the ones that batch everything into a quarterly update and then discover four breaking changes at once. Frequent small updates are boring, and boring is what you want from your dependency pipeline.


