Security essentials for Node apps

Node.js · Advanced · 15 min

Node apps run with your full privileges and pull in hundreds of packages; small mistakes become breaches.

Most Node vulnerabilities come from a handful of patterns. Learn to spot them.

Supply chain. Your dependency tree is code you run. Commit the lockfile, install with npm ci, run npm audit in CI, keep dependencies current, and be wary of tiny new packages and typo-squatted names. npm ci --ignore-scripts blocks install-time scripts where your build allows it.

Injection. Never concatenate untrusted input into SQL (use parameterised queries), shell commands (execFile with an args array, not exec), eval/new Function, or regexes (catastrophic backtracking — ReDoS — can freeze the event loop).

Path traversal. A user-supplied filename like ../../etc/passwd escapes your folder. Resolve the path and verify it stays inside the allowed root before reading.

Prototype pollution. Recursively merging untrusted JSON can set __proto__ and change every object's behaviour. Validate input against a schema, use Object.create(null) or Map for dictionaries, and avoid hand-written deep merges.

Denial of service. Limit request body sizes, set timeouts (server.requestTimeout, headersTimeout), paginate queries, and keep CPU-heavy work off the main thread.

Secrets. Load from the environment or a secrets manager; never log them or commit them. Compare tokens with crypto.timingSafeEqual.

Least privilege. Don't run as root. Node 22 includes a permission model: start with --permission and the process cannot touch the file system, spawn children or start workers unless you grant it, e.g. --allow-fs-read=/app --allow-fs-write=/tmp. Check at runtime with process.permission.has("fs.write", path). It limits damage from compromised code, but it is not a sandbox for malicious code.

Keep Node itself patched: security releases land on LTS lines regularly.

Example

import path from "node:path";
import { readFile } from "node:fs/promises";
import { timingSafeEqual, createHash } from "node:crypto";

const PUBLIC_ROOT = path.resolve(import.meta.dirname, "public");

// Path traversal guard
async function readPublicFile(userPath) {
  const full = path.resolve(PUBLIC_ROOT, userPath);
  if (!full.startsWith(PUBLIC_ROOT + path.sep)) throw new Error("forbidden path");
  return readFile(full);
}
await readPublicFile("../../etc/passwd").catch((e) => console.log(e.message)); // forbidden path

// Constant-time token comparison (hash first so lengths match)
function safeEqual(a, b) {
  const ha = createHash("sha256").update(a).digest();
  const hb = createHash("sha256").update(b).digest();
  return timingSafeEqual(ha, hb);
}
console.log(safeEqual("secret-token", process.env.API_TOKEN ?? ""));

// Run with least privilege:
//   node --permission --allow-fs-read=./ app.js
console.log("permission model on:", process.permission !== undefined);

Remember

  • Lockfile + npm ci + npm audit; minimise dependencies
  • Parameterise queries and avoid shells to prevent injection
  • Resolve and check paths to stop traversal
  • --permission restricts fs, child processes and workers

Watch out for

  • Checking userPath.includes('..') instead of resolving and comparing the final path.
  • Treating the permission model as a sandbox for untrusted code.

Check yourself

  1. A route serves files with readFile(path.join(root, req.query.name)). What's the main risk?
  2. What does starting Node with --permission do by default?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: nodejs.org/learn/getting-started/security-best-practices