Security & Production Hardening
A senior's checklist: dependency risk, input limits, secrets, permissions, and safe defaults.
Most Node breaches are boring: an outdated dependency, a leaked token, an unbounded request body. Harden the boring stuff and you have closed most of the door.
Dependencies are your biggest attack surface
npm auditin CI, and commit your lockfile so builds are reproducible.- Install with
npm ciin CI/production — it installs exactly the lockfile, never mutates it. - Use
npm install --ignore-scriptsor a policy where possible; postinstall scripts are a common supply-chain vector. - Fewer dependencies is a security feature. Vet before you add.
Runtime hardening
- Limit input — cap request body size, JSON depth, and array lengths. An unbounded body is a trivial DoS.
- Never build shell strings — use
execFile/spawnwith an args array, neverexecwith interpolated input (command injection). - Validate at the boundary — treat every external input as hostile; validate with a schema before it reaches business logic.
- Run as non-root, read-only filesystem where possible, drop container capabilities.
// SAFE: arguments are passed as data, not parsed by a shell.
const { execFile } = require('node:child_process');
execFile('git', ['log', '--oneline', userBranch], (err, out) => { /* ... */ });
// UNSAFE: userBranch could be '; rm -rf /'
// exec(`git log ${userBranch}`)The Permission Model
Node's --permission flag (stabilizing in the 24-era) lets you deny filesystem, child-process, and worker access by default and allow-list only what the app needs — real defense in depth for running third-party code.
Example
// A hardened HTTP entrypoint: body-size cap, timeouts, safe subprocess use,
// schema-validated input, and least-privilege headers. Framework-agnostic.
const http = require('node:http');
const { execFile } = require('node:child_process');
const { z } = require('zod');
const MAX_BODY = 64 * 1024; // 64 KB — reject anything larger, unread.
const Query = z.object({ repo: z.string().regex(/^[\w.-]{1,64}$/) });
const server = http.createServer((req, res) => {
let size = 0;
const chunks = [];
req.on('data', (c) => {
size += c.length;
if (size > MAX_BODY) { res.writeHead(413).end('Payload Too Large'); req.destroy(); return; }
chunks.push(c);
});
req.on('end', () => {
// Validate BEFORE using input; reject on any mismatch.
const parsed = Query.safeParse(JSON.parse(Buffer.concat(chunks).toString() || '{}'));
if (!parsed.success) { res.writeHead(400).end('Bad Request'); return; }
// execFile with an args array — no shell, no injection.
execFile('git', ['ls-remote', parsed.data.repo], { timeout: 5000 }, (err, stdout) => {
if (err) { res.writeHead(502).end('Upstream error'); return; }
res.writeHead(200, {
'Content-Type': 'application/json',
'X-Content-Type-Options': 'nosniff', // don't let browsers sniff types
});
res.end(JSON.stringify({ lines: stdout.split('\n').length }));
});
});
});
// Timeouts stop slow-loris style resource exhaustion.
server.requestTimeout = 10_000;
server.headersTimeout = 5_000;
server.listen(3000);When to use it
- A fintech API enables `--experimental-permission` in Node 20 to restrict the worker process to reading only a specific data directory, containing a path-traversal exploit.
- A SaaS platform sets `Content-Security-Policy`, `Strict-Transport-Security`, and `X-Frame-Options` headers via `helmet` to mitigate common web attack vectors.
- A DevSecOps pipeline runs `npm audit --audit-level=high` and fails the build if any high-severity CVE is found in the dependency tree.
More examples
Harden HTTP headers with helmet
Configures `helmet` with a strict Content Security Policy that allows scripts only from the same origin.
const express = require('express');
const helmet = require('helmet');
const app = express();
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
},
},
}));
app.listen(3000);Enable Node permission model
Uses Node 20's experimental permission model to sandbox the process to specific filesystem paths.
# Node 20+ experimental permission model
# Restrict to reading files only in ./data
node --experimental-permission \
--allow-fs-read=$(pwd)/data \
--allow-fs-write=$(pwd)/output \
server.js
# Any read outside ./data throws ERR_ACCESS_DENIEDSanitize and validate user input
Validates request body shape with `zod` and sanitizes HTML content with `DOMPurify` before persisting it.
const { z } = require('zod');
const DOMPurify = require('isomorphic-dompurify');
const BodySchema = z.object({
title: z.string().max(200),
content: z.string().max(10_000),
});
app.post('/posts', (req, res) => {
const { title, content } = BodySchema.parse(req.body);
const safeContent = DOMPurify.sanitize(content);
// save safeContent to DB
res.status(201).json({ ok: true });
});
Discussion