Security Best Practices

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every code example was run on all three lines unless the text says otherwise. It follows the structure of the official Node.js security guide, which lists the threats a Node.js application has to account for, each tagged with a Common Weakness Enumeration (CWE) number, and it adds what the guide only points at: the npm supply chain after npm 12, the hardening flags, and how security releases work. The APIs used are mostly Stability 2 (Stable): node:http, node:crypto, node:child_process, node:vm and node:inspector are Stable, and the Permission Model is Stable too, although its --allow-net flag is Stability 1.1 (Active development). --frozen-intrinsics is Stability 1 (Experimental), and node:sqlite, used for the parameterised-query example, is Stability 1.2 (Release candidate). Security is a property of the whole application: this page is the map, and the deeper dives live in Server Timeouts, Limits & Hardening, The Permission Model, Crypto, Passwords & Secrets and npm & Package Management. The running scenario is the Bookshelf application that grows across this section, described in Introduction & Architecture.

The stability and version map

Table 1. APIs, flags and tools covered on this page
API or flag Stability (Node.js 26 docs) Version notes

node:http server options (headersTimeout, requestTimeout, keepAliveTimeout, maxRequestsPerSocket, insecureHTTPParser)

2 Stable

The timeout options of createServer() are supported since v18.0.0; the --insecure-http-parser flag was added in v13.4.0, v12.15.0 and v10.19.0.

crypto.timingSafeEqual(), crypto.scrypt(), crypto.argon2(), crypto.secureHeapUsed()

Stable (node:crypto is 2 Stable)

timingSafeEqual() added in v6.6.0 (accepts ArrayBuffer since v15.0.0); argon2() added in v24.7.0, so it does not exist on Node.js 22.

child_process.execFile() and spawn()

2 Stable

Passing an args array together with shell: true is DEP0190: documentation-only since v23.11.0 and v22.15.0, a runtime deprecation since v24.0.0.

node:vm

2 Stable

The module documents itself as not a security mechanism; do not use it to run untrusted code.

--frozen-intrinsics

1 Experimental

Added in v11.12.0; prints an ExperimentalWarning on all three lines.

--disable-proto=mode

Stable (no stability label)

Added in v13.12.0 and v12.17.0; delete removes Object.prototype.__proto__, throw makes access throw ERR_PROTO_ACCESS.

--secure-heap=n, --secure-heap-min=n

Stable (no stability label)

Added in v15.6.0; n must be a power of two. The official guide notes that a secure heap is not available on Windows.

--disallow-code-generation-from-strings

Stable (no stability label)

Added in v9.8.0; does not affect node:vm.

--disable-sigusr1

Stable (no longer experimental)

Added in v23.7.0 and v22.14.0; no longer experimental since v24.8.0 and v22.20.0.

--no-addons, --no-global-search-paths

Stable (no stability label)

--no-addons since v16.10.0 and v14.19.0; --no-global-search-paths since v16.10.0.

--permission and the --allow-fs-*, --allow-child-process, --allow-worker flags

2 Stable

Added in v20.0.0; marked stable in v23.5.0 and v22.13.0. --allow-net is 1.1 Active development, added in v25.0.0.

npm audit, npm audit signatures, npm ci

npm CLI commands

Present in npm 10, 11 and 12. Node.js 26 and 24 bundle npm 11.19.x, Node.js 22 bundles npm 10.9.9; npm 12 (latest 12.2.0) is not bundled with any Node.js line.

min-release-age (npm config)

npm CLI setting

Per the official guide, npm v11.10.0 and later. Present in the npm 11 and npm 12 documentation, absent from npm 10.

allowScripts, npm approve-scripts, npm deny-scripts, strict-allow-scripts

npm CLI features

Install scripts are blocked by default in npm 12 (observed with npm 12.2.0); npm 11.19.1 only warns (observed).

The threat model: what is, and is not, a Node.js vulnerability

The official guide opens by saying that it extends the Node.js threat model, which is the project’s definition of what counts as a vulnerability in Node.js itself. The idea is that Node.js treats some things as trusted: the code it is asked to run (your application and its dependencies), the file system the process can reach, and the inputs your own code hands to its APIs. A problem that needs one of those to be hostile is, by that definition, not a bug in Node.js core: it is your deployment’s problem. The practical consequence is the most important sentence on this page: the runtime will not save an application from a malicious dependency, a poisoned directory or unvalidated input, so those defences belong to you. The project’s own policy, including the threat model section, is in the SECURITY.md of the Node.js repository, listed in the references.

Table 2. The official threat list and where this page covers each item
Threat (guide section) CWE Core question Section on this page

Denial of service of the HTTP server

CWE-400

Can slow, broken or numerous clients exhaust sockets, memory or the event loop?

Denial of service of the HTTP server (CWE-400)

DNS rebinding

CWE-346

Can a web page reach a service (typically the inspector) bound to localhost?

DNS rebinding (CWE-346)

Exposure of sensitive information to an unauthorized actor

CWE-552

Does npm publish ship files that were never meant to leave the machine?

Exposure of sensitive information (CWE-552)

HTTP request smuggling

CWE-444

Do a proxy and Node.js disagree about where a request ends?

HTTP request smuggling (CWE-444)

Information exposure through timing attacks

CWE-208

Does the time to answer reveal how much of a secret was right?

Information exposure through timing attacks (CWE-208)

Malicious third-party modules

CWE-1357

What happens when a dependency, direct or transitive, turns hostile?

Malicious third-party modules and the supply chain (CWE-1357)

Memory access violation

CWE-284

Can secrets leak from process memory on a shared machine?

Memory access violation (CWE-284)

Monkey patching

CWE-349

Can code replace built-in behaviour at run time?

Monkey patching (CWE-349)

Prototype pollution

CWE-1321

Can user input add properties to Object.prototype?

Prototype pollution (CWE-1321)

Uncontrolled search path element

CWE-427

Does module resolution pick up a file nobody intended to load?

Uncontrolled search path element (CWE-427)

Node.js Permission Model

(complement to the list)

Can the process be limited to what it needs?

The Permission Model: a seat belt, not a sandbox

Two items in the official list are labelled as application-level threats even though the guide discusses them next to the others: malicious third-party modules and prototype pollution (and the same holds for the uncontrolled search path). The guide says in so many words that they are not vulnerabilities in Node.js core under the threat model, but that they are serious for the applications and libraries built on it. The rest of this page treats them that way: as things you defend against in your own code and pipeline.

Denial of service of the HTTP server (CWE-400)

A denial of service (DoS) makes the application unavailable for the purpose it was built for. It does not need a malicious actor: a buggy client that retries in a tight loop has the same effect. The official guide draws a precise line. The Node.js HTTP server does not parse the request body and hands it to your handler, so a DoS that is triggered by the contents of a body (a pathological JSON document, a regular expression that backtracks) is the application’s responsibility. What the runtime does expect you to configure is everything about the timing and pattern of requests, because that is where an attack such as Slowloris lives: the client sends a request one fragment at a time, the server keeps resources reserved for every unfinished request, and enough of those exhaust the connection limit.

The guide gives three mitigations: put a reverse proxy in front of Node.js, configure the server timeouts, and limit open sockets (agent.maxSockets, agent.maxTotalSockets, agent.maxFreeSockets and server.maxRequestsPerSocket). It also warns about a smaller trap that is easy to miss: a server created without an 'error' listener on its sockets can crash the whole process on a bad request. The example sets the timeouts, answers malformed requests with a 400 through the 'clientError' event, and shows the equivalent listener for a raw node:net server.

A server that survives slow, idle and malformed clients (dos.mjs)
import http from 'node:http';
import net from 'node:net';

const server = http.createServer((req, res) => {
  res.end('ok\n');
});

// Close slow or idle connections instead of keeping resources dedicated to them.
server.headersTimeout = 10_000;      // time allowed to receive the complete headers
server.requestTimeout = 30_000;      // time allowed to receive the whole request
server.keepAliveTimeout = 5_000;     // idle keep-alive connections
server.maxRequestsPerSocket = 100;   // recycle long-lived connections

// Handle socket-level errors so a bad client cannot crash the process.
server.on('clientError', (err, socket) => {
  if (socket.writable) socket.end('HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n');
  else socket.destroy();
});

// A raw net server needs the same care: always attach an 'error' listener.
const echo = net.createServer((socket) => {
  socket.on('error', (err) => console.error('socket error:', err.code));
  socket.pipe(socket);
});

server.listen(0, '127.0.0.1', () => {
  const { port } = server.address();
  // Send a malformed request line and check that the server answers and survives.
  const c = net.connect(port, '127.0.0.1', () => c.write('NOT HTTP AT ALL\r\n\r\n'));
  c.on('data', (d) => console.log(d.toString().split('\r\n')[0]));
  c.on('close', async () => {
    const res = await fetch(`http://127.0.0.1:${port}/`);
    console.log('still serving:', res.status, (await res.text()).trim());
    server.close(); echo.close();
  });
});
echo.listen(0, '127.0.0.1');
Output (identical on Node.js 26, 24 and 22)
HTTP/1.1 400 Bad Request
still serving: 200 ok

The values above are illustrative. Choosing them, the size and connection limits, and the interaction with a reverse proxy are the subject of Server Timeouts, Limits & Hardening. The other DoS family, a handler that burns the CPU or blocks the loop for everyone, is in Don’t Block the Event Loop. A third family arrives with data you did not size: unbounded request bodies, unbounded uploads and decompression bombs (see Compression with zlib); always cap what you read.

DNS rebinding (CWE-346)

This attack targets applications that run with the debugging inspector enabled (--inspect). Browsers allow any web page to send HTTP and WebSocket requests, and the same-origin policy normally stops a page from reading answers from a local address. DNS rebinding defeats that: the attacker controls both a website and the DNS server that resolves its name, so for a moment the name resolves to the victim’s local address and the browser believes the request is same-origin. If an inspector is listening on 127.0.0.1:9229, such a page can talk to it, and the inspector protocol can run arbitrary code in your process.

The mitigations in the guide are to keep the inspector out of production and to stop it from being started on demand by a signal. By default, a Node.js process that receives SIGUSR1 starts the inspector, so a signal is enough to open the door; the guide suggests attaching a process.on('SIGUSR1', …​) listener to prevent that, and the --disable-sigusr1 flag does the same without code (it was experimental when added, and is no longer experimental since v24.8.0 and v22.20.0). The CLI reference also warns that binding the inspector to a public address is insecure; the default is 127.0.0.1:9229, and --inspect=0 picks a random port.

The same trick works against any service that listens on localhost and trusts the network location of its caller: development servers, admin ports, sidecars. A general, application-level defence is to validate the Host header and refuse names you do not serve, so a rebinding page that arrives under the attacker’s host name gets a 421.

Refusing requests addressed to an unexpected host (rebind.mjs)
import http from 'node:http';

// Only answer for host names we expect. A DNS-rebinding page reaches this server
// with the attacker's host name in the Host header, not "localhost".
const allowedHosts = new Set(['localhost', '127.0.0.1', '[::1]']);

const server = http.createServer((req, res) => {
  const host = (req.headers.host ?? '').replace(/:\d+$/, '');
  if (!allowedHosts.has(host)) {
    res.writeHead(421, { 'content-type': 'text/plain' }).end('Misdirected Request\n');
    return;
  }
  res.end('hello\n');
});

server.listen(0, '127.0.0.1', async () => {
  const { port } = server.address();
  const ask = (host) => new Promise((resolve) => {
    http.get({ host: '127.0.0.1', port, headers: { host } }, (res) => {
      res.resume(); res.on('end', () => resolve(`${host} -> ${res.statusCode}`));
    });
  });
  console.log(await ask(`localhost:${port}`));
  console.log(await ask(`evil.example:${port}`));
  server.close();
});
Output (the port varies; identical on Node.js 26, 24 and 22)
localhost:50307 -> 200
evil.example:50307 -> 421

The second half of the defence is to make the signal harmless. The script below installs a SIGUSR1 listener and sends the signal to itself. I observed on all three lines that, without a listener (and without --disable-sigusr1), the same signal makes the process print "Debugger listening on ws://127.0.0.1:9229/…​"; with the listener, or with the flag, it does not.

Keeping SIGUSR1 from opening the inspector (sigusr1.mjs)
// Keep a SIGUSR1 listener so the default "start the inspector" behaviour is not used.
process.on('SIGUSR1', () => console.log('SIGUSR1 ignored, inspector not started'));
setTimeout(() => process.kill(process.pid, 'SIGUSR1'), 50);
setTimeout(() => process.exit(0), 300);
Output
SIGUSR1 ignored, inspector not started

Debugging safely, including SSH tunnels instead of public bindings, is in Debugging.

Exposure of sensitive information (CWE-552)

The guide’s example of this CWE is a publishing accident: when you publish a package, everything in the current directory is pushed to the npm registry unless a mechanism excludes it. A .env file, a notes.txt, a private key or a local database ends up in a public tarball, and once somebody has downloaded it you cannot take it back. There are two mechanisms: a blocklist (the ignore files .npmignore and .gitignore) and an allowlist, the files field in package.json. The allowlist is the safer default because a new file is excluded until somebody opts it in. The guide’s checklist is to review what will be published with a dry run, maintain the ignore files, and unpublish in case of an exposure.

The check is one command, and it is worth running in CI: it prints exactly what would be in the tarball without publishing anything. The package here has a .env and a notes.txt next to dist/ and a files allowlist; only the allowlisted file (and package.json, which npm always includes) is listed.

An allowlist in package.json and a dry run
{
  "name": "@example/bookshelf-lib",
  "version": "1.0.0",
  "type": "module",
  "exports": "./dist/index.js",
  "files": ["dist"]
}
Previewing the tarball (observed with npm 11.19.1)
npm pack --dry-run        # or: npm publish --dry-run
Output (trimmed)
npm notice Tarball Contents
npm notice 20B dist/index.js
npm notice 134B package.json
npm notice total files: 2

The same principle applies to every channel a secret can leak through: keep secrets in the environment (loaded with --env-file, see CLI Options, Environment & Config File), keep the .env file out of version control and Docker build contexts, never put them in URLs (they end up in logs and proxies), and never log them. The CLI reference lists report options such as --report-exclude-env for diagnostic reports, and --no-extra-info-on-fatal-exception hides extra detail on fatal exceptions; the handling of secrets in code is the subject of Crypto, Passwords & Secrets.

HTTP request smuggling (CWE-444)

Smuggling needs two HTTP servers, typically a proxy in front and Node.js behind it. If the front end and the back end read the same bytes as different requests (for example because a message carries both Content-Length and Transfer-Encoding), an attacker can hide a second request inside the first one: the proxy never sees it, the application does, and the proxy’s checks are bypassed. Whether a given case is a Node.js vulnerability depends on the specification: if Node.js interprets the message consistently with RFC 7230, then it is not considered a bug in Node.js, and the fix belongs in the front end (or in both).

The guide’s mitigations are: never enable insecureHTTPParser when creating an HTTP server, configure the front-end server to normalise ambiguous requests, monitor for new smuggling vulnerabilities in both Node.js and the front end, and use HTTP/2 end to end where possible (and disable HTTP downgrading). The leniency flag exists for interoperability with non-conformant clients; the CLI reference lists what it accepts, including a message that has both Transfer-Encoding and Content-Length. The experiment sends exactly that ambiguous message to a strict server (the default) and to one with the flag on.

The strict parser rejects an ambiguous message (smuggle.mjs)
import http from 'node:http';
import net from 'node:net';

// insecureHTTPParser defaults to false: leave it that way.
const strict = http.createServer({ insecureHTTPParser: false }, (req, res) => res.end('handled\n'));
const lenient = http.createServer({ insecureHTTPParser: true }, (req, res) => res.end('handled\n'));

// An ambiguous message: both Content-Length and Transfer-Encoding.
const ambiguous =
  'POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\nConnection: close\r\n\r\n0\r\n\r\n';

function send(server) {
  return new Promise((resolve) => {
    server.listen(0, '127.0.0.1', () => {
      const c = net.connect(server.address().port, '127.0.0.1', () => c.write(ambiguous));
      let data = '';
      c.on('data', (d) => (data += d));
      c.on('close', () => { server.close(); resolve(data.split('\r\n')[0]); });
    });
  });
}
console.log('strict parser :', await send(strict));
console.log('lenient parser:', await send(lenient));
Output (identical on Node.js 26, 24 and 22)
strict parser : HTTP/1.1 400 Bad Request
lenient parser: HTTP/1.1 200 OK

The second row is the point: the lenient parser accepted the ambiguous request, and a proxy that treats it differently is the opening smuggling needs. Run the strict parser, and see Server Timeouts, Limits & Hardening for the proxy side (keep-alive alignment and who the client is).

Information exposure through timing attacks (CWE-208)

A timing attack learns a secret from how long the application takes to respond. It is not specific to Node.js; it is possible whenever a secret is used in a timing-sensitive operation. The guide’s example is a login: comparing the submitted password with the stored one using the built-in string comparison returns at the first differing character, so each additional correct prefix makes the response a little slower, and an attacker who can send many requests can recover the value.

The guide’s mitigations are crypto.timingSafeEqual(), which compares two values with a constant-time algorithm, and scrypt for passwords; more generally, avoid branching on secrets, and avoid using a secret as an index into memory when an attacker could share the machine. It adds that writing constant-time code in JavaScript is hard (partly because of the JIT), so use the built-in crypto APIs or WebAssembly rather than rolling your own. Three details trip people up. timingSafeEqual() throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH when the two inputs have different byte lengths, so hash both sides to a fixed size (or compare fixed-length digests) before comparing. The function does not turn a weak secret into a strong one. And a recently discovered case shows the lesson at the runtime level: the March 2026 security release fixed a non-constant-time comparison in the native HMAC verification code (CVE-2026-21713), which is a reminder to keep Node.js patched and to use the current APIs rather than hand-built ones.

Constant-time comparison for API keys, webhook signatures and passwords (timing.mjs)
import { createHash, createHmac, randomBytes, scrypt, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

const scryptAsync = promisify(scrypt);

// 1. Compare secrets of unknown length: hash both sides to a fixed size first,
//    because timingSafeEqual() throws when the lengths differ.
function safeEqual(a, b) {
  const da = createHash('sha256').update(a).digest();
  const db = createHash('sha256').update(b).digest();
  return timingSafeEqual(da, db);
}

// 2. Verify an HMAC webhook signature in constant time.
const secret = randomBytes(32);                 // in real code: read from process.env
const body = '{"event":"book.created"}';
const signature = createHmac('sha256', secret).update(body).digest();
const received = Buffer.from(signature.toString('hex'), 'hex');   // what the client sent, decoded from its header
console.log('hmac ok:', timingSafeEqual(createHmac('sha256', secret).update(body).digest(), received));

// 3. Passwords: store a salted scrypt hash with its cost parameters, never the password, and compare in constant time.
//    N = 2^17, r = 8, p = 1 is the OWASP minimum; it needs about 128 MiB, so maxmem is raised above its 32 MiB default.
const SCRYPT = { N: 2 ** 17, r: 8, p: 1 };
const MAXMEM = 256 * 1024 * 1024;
async function hashPassword(password) {
  const salt = randomBytes(16);
  const key = await scryptAsync(password.normalize('NFKC'), salt, 64, { ...SCRYPT, maxmem: MAXMEM });
  return `scrypt$${SCRYPT.N}$${SCRYPT.r}$${SCRYPT.p}$${salt.toString('hex')}$${key.toString('hex')}`;
}
async function verifyPassword(password, stored) {
  const [, N, r, p, saltHex, keyHex] = stored.split('$');  // the stored parameters, so they can be raised later
  const expected = Buffer.from(keyHex, 'hex');
  const key = await scryptAsync(password.normalize('NFKC'), Buffer.from(saltHex, 'hex'), expected.length,
    { N: Number(N), r: Number(r), p: Number(p), maxmem: MAXMEM });
  return timingSafeEqual(key, expected);
}

const stored = await hashPassword('correct horse battery staple');
console.log('same-length api keys equal:', safeEqual('abcd', 'abcd'));
console.log('different-length keys equal:', safeEqual('abcd', 'abcdef'));
console.log('right password:', await verifyPassword('correct horse battery staple', stored));
console.log('wrong password:', await verifyPassword('wrong', stored));
console.log('stored format:', stored.split('$').slice(0, 4).join('$') + '$<salt>$<key>');
try { timingSafeEqual(Buffer.from('ab'), Buffer.from('abc')); } catch (e) { console.log(e.code); }
Output (identical on Node.js 26, 24 and 22)
hmac ok: true
same-length api keys equal: true
different-length keys equal: false
right password: true
wrong password: false
stored format: scrypt$131072$8$1$<salt>$<key>
ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH

Do not leave crypto.scrypt() at its defaults for passwords: the default cost N is 16384 (214), eight times below the OWASP minimum of 217. On Node.js 24.7 and later, crypto.argon2('argon2id', …​) is the first choice. The password parameters (cost, salt length, storing them next to the hash, Argon2id, PBKDF2 iteration counts) are covered in Crypto, Passwords & Secrets: password hashing, and authenticated encryption in the same page; session and token handling is in Authentication & Sessions.

Malicious third-party modules and the supply chain (CWE-1357)

Node.js treats the code it is asked to run, dependencies included, as trusted, so a malicious dependency is not a Node.js vulnerability. It is nevertheless, in the words of the guide, one of the most critical application-level risks. The reason is structural: any package in node_modules runs with the full privileges of your process. It can read the file system, open sockets, read process.env (where your database password lives), call eval(), and write files that will be loaded later. The guide gives two typical stories: a maintainer account of a popular logging library is compromised and a new minor version sends environment variables to a remote server when the logger initialises, and a typosquatted package with a name close to a well-known framework runs a postinstall script that uploads SSH keys.

A supply chain attack is the general form: one of your direct or transitive dependencies is compromised, and your application picks the bad version up because the specification was too lax (a range that admits new versions) or contained a typo. The guide lists the attack vectors: typosquatting, lockfile poisoning, compromised maintainers, malicious packages and dependency confusion. It also makes a point that is easy to miss: pinning a dependency to an exact version does not pin its transitive dependencies, so exact versions in package.json are not enough; only a lockfile pins the whole tree.

Table 3. Supply-chain vectors and the controls that address them
Vector What happens Controls

Typosquatting

A package whose name is one keystroke away from a popular one is installed by mistake, and runs install scripts or exfiltrates data.

Read names in package.json and in pull requests that change it; copy install commands from the project’s own documentation; check downloads, age and maintainers before adding a package. The npm documentation says npm detects and blocks typosquats at publish time, but not every one.

Dependency confusion

A public package is registered with the name of a private one, and the resolver prefers it.

Use scoped packages for private code and own the scope on the public registry; the npm documentation recommends scopes and says npm cannot detect this attack itself.

Compromised maintainer

An account is taken over (weak password, expired email domain) and a new version of a legitimate package carries malicious code.

Dependency cool-down (min-release-age), lockfiles with reviewed updates, install-script policy, provenance checks, least privilege at run time (Permission Model); maintainers should use two-factor authentication and trusted publishing.

Lockfile poisoning

A pull request edits package-lock.json so that a resolved URL or integrity value points somewhere else.

Review lockfile diffs like code; install with npm ci, which fails when package.json and the lockfile disagree; verify signatures with npm audit signatures.

Malicious package, new or old

A package is published with hostile code from day one, or an old version is found to be hostile.

npm audit in CI; report malware to npm; static analysis tools (the guide names Socket as one example); keep the dependency tree small.

Install-time scripts

preinstall, install and postinstall of any dependency run arbitrary code on the developer machine and in CI the moment you install.

--ignore-scripts, or npm 12’s allowScripts policy (see below), plus building in an isolated CI job without publish credentials.

The baseline: lockfile, npm ci, audit and signatures

The guide’s mitigations map onto a handful of commands. Commit the lockfile; install with npm ci in CI and in Docker builds (it requires a lockfile, removes an existing node_modules, never writes to package.json or package-lock.json, and fails if the two disagree); run npm audit on every build and fail on vulnerabilities above a threshold; and run npm audit signatures, which verifies the registry signatures of what you downloaded and also verifies provenance attestations where a package has them. Registry signatures prove that the tarball is the one the registry published and was not altered in transit; provenance attestations go further and link a version to the source repository and the build that produced it. Neither says that the code is good: a compromised maintainer can publish a signed, attested package. They narrow down where a package came from, they do not review it.

The project below uses a project-level .npmrc that enables a three-day cool-down, saves exact versions and sets the audit threshold. The sequence was run with npm 11.19.1 (the npm bundled with Node.js 26).

A project-level .npmrc
min-release-age=3
save-exact=true
audit-level=high
The commands to run in CI (observed with npm 11.19.1 and Express 5.2.1)
npm ci
npm audit --audit-level=high
npm audit signatures
Output (trimmed)
found 0 vulnerabilities
audited 68 packages in 0s

68 packages have verified registry signatures

1 package has a verified attestation
(use --json --include-attestations to view attestation details)

npm ci is also the reproducibility check: after changing package.json by hand to a range that the lockfile does not satisfy (Express ^4.0.0 while the lockfile holds 5.2.1), npm ci stopped with code EUSAGE and said the lockfile was not in sync, instead of silently resolving something new. Dropping --ignore-scripts or --min-release-age flags from a CI job is how that protection is lost, so keep them in .npmrc where review sees changes. The npm page of this section has the full installation flow: npm & Package Management.

Install scripts: --ignore-scripts and the npm 12 policy

Install scripts are the most direct way a dependency gets code execution on a machine, which is why the guide’s first mitigation is to prevent npm from running them with --ignore-scripts (or npm config set ignore-scripts true to disable them globally). The cost is that packages that legitimately compile or download a native binary (the observed example below is esbuild) then need the script run deliberately. npm 12 (12.0.0, 2026-07-08) turns this into a policy: dependency install scripts (preinstall, install, postinstall, and prepare for non-registry sources) are blocked unless the root package.json approves them in an allowScripts field. The npm documentation describes the commands npm approve-scripts and npm deny-scripts; the npm 12.2.0 output I observed points to npm install-scripts approve and npm install-scripts deny, so both spellings appear in the tooling.

The comparison below is observed output, not documentation text: the same project (esbuild, which has a postinstall script) was installed with the npm 11.19.1 that Node.js 26 bundles, and with npm 12.2.0 installed separately in a scratch directory, because npm 12 is not bundled with any Node.js line.

Table 4. What each npm does with a dependency that has an install script (observed)
npm Observed behaviour

11.19.1 (bundled with Node.js 26 and 24)

Installs and prints npm warn install-scripts 1 package has install scripts not yet covered by allowScripts with the package and its script (postinstall: node install.js). It is a warning; the policy is not enforced.

12.2.0 (not bundled)

Installs the package but prints 1 package had install scripts blocked because they are not covered by allowScripts. The script does not run. npm approve-scripts --allow-scripts-pending lists the blocked package without changing anything.

12.2.0 with npm approve-scripts esbuild

Writes a pinned entry to package.json, "allowScripts": { "esbuild@0.28.2": true }. The approval is narrowed to the version you reviewed; --no-allow-scripts-pin writes a name-only entry that allows any version.

12.2.0 with npm ci --strict-allow-scripts

Fails with ESTRICTALLOWSCRIPTS when a dependency has an install script that is neither approved nor denied, instead of warning. A good setting for CI. --ignore-scripts and --dangerously-allow-all-scripts both override it.

The field that records the decisions (written by npm approve-scripts)
{
  "name": "t12",
  "version": "1.0.0",
  "private": true,
  "dependencies": { "esbuild": "^0.28.2" },
  "allowScripts": { "esbuild@0.28.2": true }
}

The npm documentation adds the rules that matter for review: denials (false entries) always win over a later approve-scripts; the --allow-scripts flag is for one-off contexts such as npx and npm install -g, and it is an error to pass it on a project-scoped install; and entries are matched against a dependency’s resolved identity, not the name the package claims for itself. Commit allowScripts and treat a change to it like a change to a firewall rule. On npm 10 and 11 there is no such policy, so --ignore-scripts (and running the few required scripts by hand) remains the control.

A cool-down with min-release-age

Most compromised versions are detected and removed within hours, so refusing to install anything younger than a few days removes most of the exposure to short-lived attacks. The guide recommends the min-release-age setting (npm v11.10.0 and later), whose value is in days, and notes that it can be overridden for one command when you need a fix at once (npm install package-name --min-release-age=0). The npm documentation adds three details: min-release-age-exclude exempts package names or globs (for example your own scope); an explicit --before date wins over it when both are set in the same source; and when the window prevents npm audit fix from installing a patched version that is too new, npm keeps the vulnerable version, warns and exits non-zero, so the fix needs an explicit exception.

I confirmed the setting is active with npm config get min-release-age (printed 3 with the .npmrc above, on npm 11.19.1 and npm 12.2.0), and that the filter really removes candidates: an install with an absurdly long window, npm install left-pad@latest --min-release-age=36500 --dry-run, failed with ENOVERSIONS: No versions available for left-pad. Node.js 22 bundles npm 10.9.9, which has no min-release-age; use --before or a registry proxy there, as described in npm & Package Management.

Publishing safely

If you publish packages, the supply chain is also your responsibility. Use trusted publishing (OpenID Connect from your CI provider) so that no long-lived npm token sits in a repository secret; the npm documentation says it needs npm CLI 11.5.1 or later and Node.js 22.14.0 or later. Publish with provenance so consumers can verify where the package was built, protect the maintainer account with two-factor authentication, and keep a short files allowlist (see Exposure of sensitive information (CWE-552)). Legacy (classic) npm access tokens were permanently revoked on 2025-12-09, according to the GitHub changelog: only granular access tokens and session tokens remain. The mechanics are in Workspaces & Publishing and in the JavaScript page Tooling: Bundling, npm & Publishing.

Memory access violation (CWE-284)

Memory-based attacks depend on a combination of memory-management errors and an exploitable allocator. Like every runtime, Node.js is exposed to them if the application shares a machine with code you do not control, and the guide offers two mitigations: do not run production applications on shared machines, and consider the secure heap. --secure-heap=n initialises an OpenSSL secure heap of n bytes, which OpenSSL uses for selected allocations during key generation and other operations, so that sensitive material is not exposed by pointer overruns and underruns. The heap is fixed in size and cannot be resized at run time, n must be a power of two (anything below 2 disables it), and --secure-heap-min=n sets the minimum allocation. It is disabled by default, and not available on Windows. Size it generously: when it is full, allocations fall back to ordinary memory.

crypto.secureHeapUsed() reports the state, so you can check that the flag took effect and watch utilisation.

Enabling and observing the OpenSSL secure heap (heap.mjs)
import { secureHeapUsed, generateKeyPairSync } from 'node:crypto';
console.log('before:', secureHeapUsed());
generateKeyPairSync('ed25519');
console.log('after: ', secureHeapUsed());
Running it with and without the flag (Node.js 26.10.0; byte counts vary by version)
$ node heap.mjs
before: { total: 0, used: 0, utilization: NaN, min: 2 }
after:  { total: 0, used: 0, utilization: NaN, min: 2 }

$ node --secure-heap=1048576 --secure-heap-min=32 heap.mjs
before: { total: 1048576, used: 0, utilization: 0, min: 32 }
after:  { total: 1048576, used: 544, utilization: 0.000518798828125, min: 32 }

The used figure differs between lines (544 bytes on Node.js 26, 288 on 24 and 22), and with the heap disabled Node.js 22 reports utilization: 0 and min: 0 where 26 and 24 report NaN and 2; these are observed differences, not documented guarantees, so check total rather than parsing the rest. The secure heap does not protect the memory of the JavaScript heap itself: a secret held in a string is on the V8 heap and can end up in heap snapshots and core dumps (see Memory & Leaks), so keep secrets short-lived, and keep them out of diagnostic output.

Monkey patching (CWE-349)

Monkey patching modifies properties at run time to change existing behaviour. In an application it is a legitimate (if risky) technique; in a hostile dependency it is a way to change what Array.prototype.push, JSON.parse or a built-in module method does for the whole process, without touching your code. The guide’s mitigation is --frozen-intrinsics, which recursively freezes the built-in JavaScript objects and functions. The CLI reference marks it Stability 1 (Experimental): only the root context is supported, there is no guarantee that globalThis.Array is the default intrinsic, and code (including libraries) may break under it. To let polyfills work, --require and --import modules run before the intrinsics are frozen.

The guide points out the limit as well: freezing the intrinsics does not prevent defining new globals or replacing existing ones through globalThis; for that, freeze globalThis too with Object.freeze(globalThis). The experiment runs both: after --frozen-intrinsics, replacing Array.prototype.push throws a TypeError; without the flag the replacement succeeds. After Object.freeze(globalThis), replacing globalThis.Array throws as well.

Freezing the intrinsics and the global object (frozen.mjs)
// Run with: node --frozen-intrinsics frozen.mjs
try {
  Array.prototype.push = function () { return 'hijacked'; };
  console.log('push replaced:', [].push(1));
} catch (err) {
  console.log(err.constructor.name, '-', err.message.split('\n')[0].slice(0, 60));
}
globalThis.foo = 3;                       // new globals are still allowed
console.log('globalThis.foo =', foo);
Object.freeze(globalThis);                // freeze the global object as well
try { globalThis.Array = 4; } catch (err) { console.log('global replace:', err.constructor.name); }
console.log(typeof Array);
Output with and without the flag (identical on Node.js 26, 24 and 22)
$ node --frozen-intrinsics frozen.mjs
(node:57854) ExperimentalWarning: Frozen intristics is an experimental feature and might change at any time
TypeError - Cannot assign to read only property 'push' of object ''
globalThis.foo = 3
global replace: TypeError
function

$ node frozen.mjs
push replaced: hijacked
globalThis.foo = 3
global replace: TypeError
function

Two honest caveats. The flag is experimental and may break libraries that patch built-ins on purpose (some polyfills, test frameworks and APM agents), so it needs a test run of your whole application; and a hostile dependency that already runs in your process can do far more than patch a prototype, so freezing is a tripwire, not a containment. The real fix for untrusted code is the controls in Malicious third-party modules and the supply chain (CWE-1357) and The Permission Model: a seat belt, not a sandbox.

Prototype pollution (CWE-1321)

Prototype pollution abuses __proto__, constructor, prototype and the other properties inherited from built-in prototypes to inject properties into every object of a type. The attacker needs a path where untrusted input is merged into an object: a deep merge, a "set by path" helper, or a configuration loader. The guide gives the canonical snippet (a JSON.parse result containing a __proto__ key, merged with Object.assign) and two effects: logic bugs, such as an isAdmin-style check that now passes for every object, and denial of service, such as hasOwnProperty stopping to exist after a null prototype is assigned. The guide notes that Node.js core does not count prototype pollution caused by attacker-controlled input as its own vulnerability, because the input comes from application code; it is a language characteristic that libraries and applications must defend against, as real examples show (CVE-2018-3721 in a popular utility library, CVE-2018-16487 for insecure recursive merges, CVE-2022-21824 in Node.js itself).

The mitigations in the guide are: avoid insecure recursive merges, validate untrusted input against a JSON schema, create dictionaries without a prototype (Object.create(null)), freeze prototypes (Object.freeze(MyObject.prototype)), run with --disable-proto, check own properties with Object.hasOwn(), and avoid methods inherited from Object.prototype. The experiment first shows the vulnerable merge, then a safer one that skips the dangerous keys and writes into prototype-less objects.

A vulnerable merge, a safer merge, and prototype-less dictionaries (polluted.mjs)
const payload = '{"__proto__": {"isAdmin": true}, "title": "Dune"}';

// Vulnerable: a recursive merge that walks into "__proto__".
function unsafeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] ??= {};
      unsafeMerge(target[key], source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}
unsafeMerge({}, JSON.parse(payload));
console.log('after unsafe merge, {}.isAdmin =', ({}).isAdmin);   // true: every object is polluted
delete Object.prototype.isAdmin;

// Safer: skip dangerous keys, or build data objects without a prototype.
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target, source) {
  for (const key of Object.keys(source)) {
    if (FORBIDDEN.has(key)) continue;
    const value = source[key];
    if (typeof value === 'object' && value !== null && !Array.isArray(value)) {
      target[key] = safeMerge(Object.hasOwn(target, key) ? target[key] : Object.create(null), value);
    } else {
      target[key] = value;
    }
  }
  return target;
}
const result = safeMerge(Object.create(null), JSON.parse(payload));
console.log('after safe merge, {}.isAdmin =', ({}).isAdmin, '| title =', result.title);

// Look up keys that came from users only on the object itself.
const counts = Object.create(null);
for (const word of ['constructor', 'toString', 'dune']) counts[word] = (counts[word] ?? 0) + 1;
console.log(Object.hasOwn(counts, 'toString'), Object.keys(counts));
Output (identical on Node.js 26, 24 and 22)
after unsafe merge, {}.isAdmin = true
after safe merge, {}.isAdmin = undefined | title = Dune
true [ 'constructor', 'toString', 'dune' ]

--disable-proto=mode removes the most common entry point at the process level: with delete the Object.prototype.__proto__ accessor is removed, with throw any access throws ERR_PROTO_ACCESS. The supported API, Object.getPrototypeOf(), keeps working, and JSON.parse() still creates __proto__ as an ordinary own property, which is why the flag complements, and does not replace, safe merging. It can break old libraries that read __proto__.

Disabling the proto accessor (proto.mjs)
// Run with: node --disable-proto=throw proto.mjs   (or =delete)
const o = {};
try {
  console.log(Object.getPrototypeOf(o) === Object.prototype);          // the supported API still works
  console.log(o.__proto__);
} catch (err) {
  console.log(err.code);
}
console.log(JSON.parse('{"__proto__": {"x": 1}}').x);                     // JSON.parse creates an own property
Output (identical on Node.js 26, 24 and 22)
$ node --disable-proto=throw proto.mjs
true
ERR_PROTO_ACCESS
undefined

$ node --disable-proto=delete proto.mjs
true
undefined
undefined

The March 2026 security release fixed a related runtime case, a request with a header named __proto__ that made req.headersDistinct throw an uncatchable TypeError (CVE-2026-21710, in the release notes listed under Security releases and reporting), a reminder that dictionaries keyed by attacker-chosen names deserve Object.create(null) or a Map everywhere, including in the runtime.

Uncontrolled search path element (CWE-427)

Node.js resolves modules by the Module Resolution Algorithm and assumes that the directory in which a module is requested is trusted. The threat model considers the file system reachable by the process as trusted, so a problem that needs someone to plant a file in a directory the application resolves from is not a Node.js bug, but it is a real deployment risk, particularly together with a compromised dependency or a writable directory in a container. The guide’s example is a directory with server.js, auth.js and a second entry called auth (the experiment below uses a file), where require('./auth') is described as loading auth and not auth.js, because CommonJS tries the exact name first, then appends extensions, then looks for a directory. A file somebody drops next to your code silently wins.

The experiment recreates it with an extensionless file auth planted next to auth.js.

Resolution order in CommonJS and the explicit alternatives (search/server.cjs and search/server.mjs)
// server.cjs
const path = require('node:path');

// An extensionless specifier is resolved by trying exact name, then extensions, then a directory.
console.log(require('./auth'));
console.log(require.resolve('./auth'));

// Safer: always spell the full file name, so there is nothing to guess.
console.log(require('./auth.js'));
console.log(path.basename(require.resolve('./auth.js')));

// server.mjs: ESM never guesses extensions or directory indexes.
// import auth from './auth.js';
Output (identical on Node.js 26, 24 and 22; the directory part of the path is shortened)
auth (extensionless file, planted)
<dir>/auth
auth.js (intended)
auth.js

The defences are boring and effective: write full file names with extensions in require() (ESM already requires them), make the application directory read-only at run time (container file systems and --allow-fs-write from the Permission Model both help), do not execute from directories that other users can write to (a temporary directory, an uploads directory), and do not rely on global lookup paths. The CLI flag --no-global-search-paths disables searching global paths such as $HOME/.node_modules and $NODE_PATH; I confirmed that a module only reachable through NODE_PATH loads normally, and fails with MODULE_NOT_FOUND with the flag. Module resolution itself is covered in CommonJS Modules and ECMAScript Modules, and --no-addons (see Hardening flags at a glance) prevents loading native addons altogether.

Injection: commands, SQL and paths

The official guide’s threat list does not have a section for injection, but it is the most common way real Node.js applications get compromised, and the guide’s own theme (untrusted input must not become code) applies. Three cases cover most of it, and each has one rule.

For commands, never build a shell string from input. child_process.execFile() and spawn() take an argument array, and with no shell involved there is nothing to interpret ;, $() or backticks. The trap is to add shell: true "to make it work": the arguments are then only concatenated, not escaped, which is why passing an args array together with the shell option is DEP0190 (documentation-only since v23.11.0 and v22.15.0, a runtime deprecation since v24.0.0). Pick the program from an allowlist, never from input.

For SQL, bind values with placeholders and never splice them into the statement; table and column names cannot be bound, so choose them from an allowlist too. The example uses the built-in node:sqlite (Stability 1.2, Release candidate; it still prints an ExperimentalWarning on Node.js 22.23.3), but every driver offers the same thing: ? or $1 placeholders.

For paths, resolve the input against a base directory and check that the result is still inside it before opening anything. A string startsWith() check on the unresolved input is not enough, and a bare rel.startsWith('..') rejects a legitimate file called ..notes, so the check below compares against the separator.

Argument arrays, parameterised queries and an allowlist (inject.mjs)
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { DatabaseSync } from 'node:sqlite';

const run = promisify(execFile);
const userInput = 'Dune; echo INJECTED';

// Command injection: pass arguments as an array. There is no shell to interpret ";".
const { stdout } = await run(process.execPath, ['-p', 'process.argv[1]', userInput]);
console.log('argv value:', stdout.trim());

// The shell form concatenates a string, so the same input would run a second command (shown here
// only as text; never build commands like this):
console.log('unsafe form would run: echo ' + userInput);

// An allowlist for the program itself, never taken from user input:
const ALLOWED = new Set(['git', 'node']);
function assertAllowed(cmd) { if (!ALLOWED.has(cmd)) throw new Error(`not allowed: ${cmd}`); }
assertAllowed('node');
try { assertAllowed('rm'); } catch (e) { console.log(e.message); }

// SQL injection: bind values with placeholders, never splice them into the SQL text.
const db = new DatabaseSync(':memory:');
db.exec('CREATE TABLE books (id INTEGER PRIMARY KEY, title TEXT NOT NULL)');
db.prepare('INSERT INTO books (title) VALUES (?)').run('Dune');
db.prepare('INSERT INTO books (title) VALUES (?)').run('Neuromancer');

const hostile = "x' OR '1'='1";
const rows = db.prepare('SELECT id, title FROM books WHERE title = ?').all(hostile);
console.log('hostile title matches:', rows.length);
const named = db.prepare('SELECT title FROM books WHERE title = :title').all({ title: 'Dune' });
console.log('named parameter:', named);
Output (Node.js 26 and 24; Node.js 22 adds the SQLite ExperimentalWarning first)
argv value: Dune; echo INJECTED
unsafe form would run: echo Dune; echo INJECTED
not allowed: rm
hostile title matches: 0
named parameter: [ [Object: null prototype] { title: 'Dune' } ]
Keeping a user-supplied path inside a base directory (pathtrav.mjs)
import path from 'node:path';

const root = path.resolve('/srv/bookshelf/uploads');

function resolveInside(base, userPath) {
  const target = path.resolve(base, userPath);
  const rel = path.relative(base, target);
  if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
    throw new Error('path escapes the base directory');
  }
  return target;
}
console.log(resolveInside(root, 'covers/dune.png'));
for (const bad of ['../../etc/passwd', '/etc/passwd', 'covers/../../x']) {
  try { resolveInside(root, bad); } catch (e) { console.log(bad, '->', e.message); }
}
Output (POSIX paths; identical on Node.js 26, 24 and 22)
/srv/bookshelf/uploads/covers/dune.png
../../etc/passwd -> path escapes the base directory
/etc/passwd -> path escapes the base directory
covers/../../x -> path escapes the base directory

A string check does not stop a symbolic link that points out of the base directory; resolve with fs.realpath() before the comparison when the directory can contain links, and prefer the Permission Model’s file-system allowlist as a second layer. Shell injection in depth, including the Windows .bat and .cmd case, is in Child Processes; database access with ORMs and drivers is in Databases from Node.js. Code built from strings is the fourth member of the family: --disallow-code-generation-from-strings makes eval and new Function throw an EvalError (the next section’s snippet shows it), and it does not affect node:vm.

The vm module is not a sandbox

node:vm lets you compile and run code in a separate V8 context with its own global object, and that sounds like isolation. The documentation is blunt: the module is not a security mechanism, and you must not use it to run untrusted code. The context object you pass in is made from the host realm, so any code inside can walk up its constructor chain to the host’s Function constructor and from there to process. The timeout option only limits how long synchronous code runs; it does not prevent anything else.

The escape in two lines, and why timeouts are not isolation (vm.mjs)
import vm from 'node:vm';

// The context object looks empty, but code inside it can reach the host realm through
// any object that was created outside the sandbox, including the context object itself.
const sandbox = vm.createContext({});
const leaked = vm.runInContext(`this.constructor.constructor('return typeof process')()`, sandbox);
console.log('process visible inside the "sandbox":', leaked);

const hostRealm = vm.runInContext(`this.constructor.constructor('return typeof process.pid')()`, sandbox);
console.log('host process.pid reachable:', hostRealm);

// vm timeouts only bound synchronous CPU time; they do not isolate anything.
try { vm.runInNewContext('while (true) {}', {}, { timeout: 50 }); } catch (e) { console.log(e.code); }
Output (identical on Node.js 26, 24 and 22)
process visible inside the "sandbox": object
host process.pid reachable: number
ERR_SCRIPT_EXECUTION_TIMEOUT

Source: Node.js API: VM

The practical rule: use node:vm for what it was built for (evaluating your own trusted code in a controlled global scope, for example a templating or REPL feature) and never as a boundary. If you must run untrusted code, give it a separate process (or container, or virtual machine) with no secrets, a read-only file system, no network and resource limits; a worker_threads worker is not a security boundary either, because it shares the process. The Permission Model can restrict what a whole process may do, and is covered next. --disallow-code-generation-from-strings is complementary and independent of vm:

Blocking code generation from strings (eval.mjs)
// Run with: node --disallow-code-generation-from-strings eval.mjs
try { eval('1 + 1'); } catch (e) { console.log(e.name, '-', e.message.split('\n')[0]); }
try { new Function('return 1'); } catch (e) { console.log(e.name); }
import vm from 'node:vm';
console.log('node:vm unaffected:', vm.runInNewContext('1 + 1'));
Output (identical on Node.js 26, 24 and 22)
EvalError - Code generation from strings disallowed for this context
EvalError
node:vm unaffected: 2

The Permission Model: a seat belt, not a sandbox

The official guide ends the threat list by pointing at the Permission Model, which limits what a given process may do at run time: file-system reads and writes, child processes, worker threads, native addons, WASI, and (since Node.js 25, as an Active-development feature) network access. Its value for this page is containment: even trusted code is stopped from doing things you did not grant, so a compromised dependency that tries to read ~/.ssh or spawn a shell fails with ERR_ACCESS_DENIED instead of succeeding. It is a seat belt for mistakes and for supply-chain surprises, and not a sandbox for hostile code: the project’s own security releases in 2026 fixed several bypasses (see Security releases and reporting), and the guide’s threat model still treats the process as a trust boundary you must otherwise defend.

Starting with nothing and granting one file (perm.mjs)
import { readFileSync } from 'node:fs';
console.log('read own file:', readFileSync(new URL(import.meta.url)).length > 0);
console.log('has fs.read for /etc:', process.permission.has('fs.read', '/etc/hosts'));
try { readFileSync('/etc/hosts'); } catch (e) { console.log(e.code); }
Run with --permission and one --allow-fs-read grant (identical on Node.js 26, 24 and 22)
node --permission --allow-fs-read="$PWD/perm.mjs" perm.mjs
Output
read own file: true
has fs.read for /etc: false
ERR_ACCESS_DENIED

Every flag, the audit mode, the config-file block and the limits are in The Permission Model. Two operational points belong here: grant the narrowest paths that work (a directory per concern, not *), and combine it with the other layers on this page rather than relying on it alone.

Hardening flags at a glance

The flags below are the ones the official guide and the CLI reference present as security controls. Each is a start-up option; put them in the service’s unit file, container command or the Node.js config file rather than in application code, so they are visible to whoever reviews the deployment. Always test them: several (especially --frozen-intrinsics) can break libraries.

Table 5. Security-relevant start-up flags
Flag What it does Notes

--permission, --allow-fs-read, --allow-fs-write, --allow-child-process, --allow-worker, --allow-addons, --allow-wasi, --allow-net

Deny by default and grant by capability.

Stable (v23.5.0, v22.13.0); --allow-net is 1.1 Active development.

--frozen-intrinsics

Freezes built-in objects against monkey patching.

1 Experimental; may break code; run --require and --import modules first.

--disable-proto=delete or =throw

Removes or traps Object.prototype.__proto__.

Added v13.12.0, v12.17.0; may break old libraries.

--secure-heap=n, --secure-heap-min=n

OpenSSL secure heap for key material.

Power of two; fixed size; unavailable on Windows.

--disallow-code-generation-from-strings

eval and new Function throw.

Does not affect node:vm.

--disable-sigusr1

A SIGUSR1 signal can no longer start the inspector.

v23.7.0, v22.14.0; not experimental since v24.8.0, v22.20.0. Never expose --inspect beyond localhost.

--no-addons

Disables loading native addons and the node-addons export condition.

v16.10.0, v14.19.0.

--no-global-search-paths

Does not search $HOME/.node_modules and $NODE_PATH.

v16.10.0.

--max-http-header-size=size

Caps the HTTP header size (default 16 KiB).

Default raised from 8 KiB to 16 KiB in v13.13.0. Prefer the per-server maxHeaderSize option.

--tls-min-v1.2, --tls-min-v1.3

Sets the default minimum TLS version.

TLS 1.2 has been the default since Node.js 12; raise to 1.3 only if every peer supports it.

--no-extra-info-on-fatal-exception

Hides extra information when a fatal exception ends the process.

v17.0.0.

--insecure-http-parser

Turns on HTTP parser leniency.

Do not use; see HTTP request smuggling (CWE-444).

Experimental features deserve a sentence of their own, because the guide gives it one: do not use them in production. They can change in incompatible ways, and their behaviour is not guaranteed to be securely stable. Check the Stability index of every API you rely on; this page and the rest of the section label it each time. For the full list of options and NODE_OPTIONS, see CLI Options, Environment & Config File.

The guide ends with the OpenJS Foundation’s Open Source Security Foundation (OpenSSF) tools that matter if you publish packages: the OpenSSF Scorecard, which scores a repository with automated security checks, and the OpenSSF Best Practices Badge, with which a project self-certifies. Both are useful to look at when choosing dependencies as well as for your own.

Security releases and reporting

Everything above reduces the risk, but the runtime itself has vulnerabilities, and the control that matters most is applying fixes. The Node.js project publishes security releases through a documented process, and the release lines you can run are listed in Release Lines & LTS. Only the Current, Active LTS and Maintenance lines receive them: on the dates checked for this page that was Node.js 26, 24 and 22. Anything older (Node.js 20 reached end of life on 2026-04-30, Node.js 25 on 2026-06-01) gets no fixes, whatever your application does.

Table 6. How a Node.js security report is handled (from the security reporting policy)
Stage What happens

Report

Report security bugs in Node.js through HackerOne. The page notes that submitting there requires a minimum Signal score of 1.0, and otherwise to contact the security release stewards through the OpenJS Foundation Slack. Reports are normally acknowledged within 5 days and receive a more detailed answer within 10 days.

Triage and fix

A primary handler coordinates. The problem is validated against all supported lines, the affected versions are listed, the code is audited for similar problems, and fixes are prepared for all supported releases. Fixes are held locally, not committed to the public repository, until the announcement.

Embargo

An embargo date is chosen and a CVE is requested. Typically the embargo is 72 hours from when the CVE is issued, but it can vary with the severity or the difficulty of the fix.

Announcement

On the embargo date the announcement goes to the security mailing list, the changes are pushed to the public repository, and builds are deployed to nodejs.org. The advisory appears on the Node.js blog within 6 hours of the mailing list being notified.

Third-party modules

Security bugs in third-party modules are reported to their own maintainers, not to Node.js. The Node.js project also runs an official bug bounty program through HackerOne.

To receive announcements, subscribe through the channels the policy lists (the Node.js security mailing list, a Google Group, and the Node.js blog’s Vulnerabilities category), and make "update Node.js" a routine job: run the latest patch of your line, rebuild container images when a security release appears, and pin the runtime version in CI and Docker so the update is deliberate (see Installing Node.js and Docker & Containers). process.version and process.release.lts tell a running process what it is. Download Node.js only from nodejs.org (or your OS vendor, or a version manager that verifies checksums), and verify the checksum file when you script downloads; the nodejs.org download page links the instructions for verifying signed SHASUMS files.

Table 7. The 2026 security releases, as announced on the Node.js blog (selected items)
Release Selected items (severity as announced) Relevance to this page

January 2026 (async_hooks mitigation post)

A stack overflow while async_hooks was enabled exited the process immediately, with code 7, so the try and catch could not recover; the project shipped a mitigation and recommended that libraries limit recursion depth and not rely on recovering from stack exhaustion. Affects users of frameworks and APM tools built on AsyncLocalStorage.

Denial of service of the HTTP server (CWE-400): availability depends on behaviour the language does not specify.

2026-03-24

HMAC verification used a non-constant-time comparison (CVE-2026-21713, medium); V8 HashDoS through integer-like string keys, triggered by JSON.parse() of attacker-controlled input (CVE-2026-21717, medium); a __proto__ header name could crash through req.headersDistinct (CVE-2026-21710, high); several Permission Model bypasses (low to medium), such as Unix domain sockets without --allow-net (CVE-2026-21711) and fs.realpathSync.native() (CVE-2026-21715).

Information exposure through timing attacks (CWE-208), Denial of service of the HTTP server (CWE-400), Prototype pollution (CWE-1321), The Permission Model: a seat belt, not a sandbox.

2026-06-18

WebCrypto AES integer overflow crashing the process (CVE-2026-48933, high); a TLS hostname-normalisation bypass (CVE-2026-48618, high); proxy credentials leaked in an ERR_PROXY_TUNNEL message (CVE-2026-48615, medium); a Permission Model bypass through process.report.writeReport() (CVE-2026-48617, low); HTTP/2 issues.

Exposure of sensitive information (CWE-552): secrets in error messages; The Permission Model: a seat belt, not a sandbox.

2026-07-29

HTTP/2 memory exhaustion and use-after-free (CVE-2026-56846 and CVE-2026-56848, high); Permission Model path matching over-granting file-system access (CVE-2026-58043, high); HTTPS agent mTLS identity reuse and session reuse skipping hostname verification (CVE-2026-56850 and CVE-2026-58040, medium); header truncation enabling request smuggling in forwarding proxies (CVE-2026-58044, low). Fixed in Node.js 22.23.2, 24.18.1 and 26.5.1.

HTTP request smuggling (CWE-444), The Permission Model: a seat belt, not a sandbox.

The table lists a selection of the announced items with their announced severities, copied from the blog, not a complete list; read the posts for the affected versions of each. Three patterns recur: the HTTP and HTTP/2 stacks (denial of service, smuggling), TLS hostname handling, and the Permission Model’s boundary checks. None of this is a reason to avoid Node.js; it is the evidence for treating patching as part of the application.

If you find a vulnerability, report it as the policy says (HackerOne for Node.js itself, the maintainers for a third-party module). If you find a malicious package on npm, the npm documentation has a page on reporting malware.

Putting it together for the Bookshelf

A realistic baseline for the Bookshelf service, combining the controls above, looks like this. None of it is exotic; the discipline is applying all of it.

Table 8. A layered checklist (each row is a control from this page)
Layer Control Section

Runtime

Run a supported line (26, 24 or 22) at its latest patch, pinned in CI and in the container image; read the security-release announcements.

Security releases and reporting

Process flags

--disable-proto=throw (after testing), --disallow-code-generation-from-strings when no library needs eval, --no-addons if you ship none, --disable-sigusr1, no --inspect in production, --permission with narrow grants.

Hardening flags at a glance, The Permission Model: a seat belt, not a sandbox

HTTP server

Timeouts and limits, clientError handling, strict parser, Host validation, reverse proxy in front, HTTP/2 end to end where possible.

Denial of service of the HTTP server (CWE-400), HTTP request smuggling (CWE-444), DNS rebinding (CWE-346)

Input handling

Schema validation of every external payload; parameterised queries; argument arrays and allowlists; path containment; safe merges and Object.create(null) dictionaries.

Injection: commands, SQL and paths, Prototype pollution (CWE-1321)

Secrets

Environment variables loaded with --env-file, never in code, URLs or logs; constant-time comparison; salted scrypt or argon2 for passwords.

Information exposure through timing attacks (CWE-208), Exposure of sensitive information (CWE-552)

Dependencies

Committed lockfile, npm ci, npm audit and npm audit signatures in CI, min-release-age, --ignore-scripts or npm 12 allowScripts, review of lockfile diffs, scoped private packages, a short dependency list.

Malicious third-party modules and the supply chain (CWE-1357)

Publishing

files allowlist and a dry run, trusted publishing, provenance, two-factor authentication.

Exposure of sensitive information (CWE-552), Malicious third-party modules and the supply chain (CWE-1357)

Pitfalls

Table 9. Common mistakes
Mistake Better

Treating node:vm or a worker as a sandbox

Use a separate process or container with no secrets and no network; see The vm module is not a sandbox.

Comparing secrets with ===, or calling timingSafeEqual() on values of different lengths

Hash both sides to the same length and compare digests with crypto.timingSafeEqual().

Adding shell: true to execFile() or spawn() with an argument array

Remove the shell; pass the array; allowlist the program (DEP0190).

Pinning one dependency to an exact version and calling it safe

Exact versions do not pin transitive dependencies; commit the lockfile and install with npm ci.

Running npm install in CI and Docker

Run npm ci so that a changed package.json or lockfile fails the build instead of resolving new versions.

Allowing every install script because one package needs it

On npm 12 approve the single package with a pinned version; on older npm run --ignore-scripts and the one script by hand.

Setting min-release-age and then blocking a security fix

Add the package to min-release-age-exclude or override for that command, and review the fix.

Starting the service with --inspect=0.0.0.0 "just for a minute"

Keep the inspector on localhost and tunnel to it; add --disable-sigusr1 or a SIGUSR1 listener.

Enabling insecureHTTPParser to make a client work

Fix the client or the proxy; leave the parser strict.

Merging request bodies into configuration with a recursive helper

Validate with a schema, skip __proto__, constructor and prototype, or use prototype-less objects.

Publishing from a directory that contains .env and no files allowlist

Add files, run npm pack --dry-run in CI.

Using --frozen-intrinsics in production without testing

It is Experimental and breaks some libraries; try it in staging with the full test suite.

Relying on the Permission Model alone

Treat it as one layer; keep patching, since 2026 brought several bypass fixes.

What changed recently

Table 10. Security-relevant changes, with versions
Version or date Change

Node.js 20.0.0

The Permission Model (--permission) is added; it is marked stable in v23.5.0 and v22.13.0.

Node.js 22.15.0, 23.11.0, 24.0.0

DEP0190: an args array with shell: true is deprecated (documentation-only, then a runtime deprecation in 24.0.0).

Node.js 24.0.0

url.parse() (DEP0169) becomes an application deprecation (code outside node_modules). The documentation calls its behaviour non-standard and prone to errors with security implications, says no CVEs are issued for it, and recommends the WHATWG URL API.

Node.js 22.20.0 and 24.8.0

--disable-sigusr1 is no longer experimental.

Node.js 24.7.0

crypto.argon2() and argon2Sync() are added; Node.js 22 does not have them.

Node.js 25.0.0

--allow-net arrives, still 1.1 Active development in the Node.js 26 documentation.

npm 11.10.0 (2026-02-11)

min-release-age (a dependency cool-down) arrives, and the official security guide documents it.

npm 12.0.0 (2026-07-08)

Dependency install scripts are blocked unless approved in allowScripts; approve-scripts, deny-scripts and strict-allow-scripts are the control surface.

November 2025

Legacy npm access tokens are removed and only granular tokens remain (npm documentation). Trusted publishing with OIDC avoids stored publish tokens altogether.

2026 security releases

January, March, June and July: HMAC timing, HashDoS, headers crashing, Permission Model bypasses, HTTP and TLS fixes; see Security releases and reporting.

The official guide (as checked on 2026-10-04)

Opens with the threat model, labels malicious modules, prototype pollution and the search path as application-level threats, and includes supply-chain mitigations (including min-release-age) and a Permission Model section.

Where the books are outdated

Two books were consulted for this section: Efficient Node.js by Samer Buna (O’Reilly, 2025) and Node.js Projects by Jonathan Wexler (O’Reilly, 2025). Neither covers the application security landscape described here, and the points below should be checked against the official documentation instead of the books.

Table 11. Where the books fall short on security
Topic Current state

Supply chain

Neither book covers npm ci, lockfile review, npm audit and npm audit signatures, min-release-age, or the npm 12 install-script policy. Treat these as required, not optional, today.

Permission Model

Efficient Node.js describes it as something being introduced; it is stable (--permission, --allow-fs-read, --allow-fs-write, --allow-child-process, --allow-worker), with --allow-net since Node.js 25 as an active-development feature.

Child processes

Passing an argument array together with shell: true is deprecated (DEP0190) and a runtime deprecation since Node.js 24.0.0; use argument arrays without a shell.

Password handling and secrets (Node.js Projects)

The password-manager project and several examples store or compare secrets in ways that are unsafe by today’s guidance: no plain-text storage, no hard-coded or fallback secrets, constant-time comparison, a high, tuned cost for key derivation (scrypt or argon2), authenticated encryption, and --env-file rather than committed .env files.

Web application protections (Node.js Projects)

The web examples lack CSRF protection, rate limiting and safe cookie flags, and one example used a view-engine release with a known vulnerability (CVE-2022-29078); verification links should carry a random token, not an email address, and database access lists should not allow all addresses.

Versions

Both books' baselines (Node.js 18 and 20) are end of life, so they predate the 2026 security releases listed above.

References