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
| API or flag | Stability (Node.js 26 docs) | Version notes |
|---|---|---|
|
2 Stable |
The timeout options of |
|
Stable ( |
|
|
2 Stable |
Passing an |
|
2 Stable |
The module documents itself as not a security mechanism; do not use it to run untrusted code. |
|
1 Experimental |
Added in v11.12.0; prints an |
|
Stable (no stability label) |
Added in v13.12.0 and v12.17.0; |
|
Stable (no stability label) |
Added in v15.6.0; |
|
Stable (no stability label) |
Added in v9.8.0; does not affect |
|
Stable (no longer experimental) |
Added in v23.7.0 and v22.14.0; no longer experimental since v24.8.0 and v22.20.0. |
|
Stable (no stability label) |
|
|
2 Stable |
Added in v20.0.0; marked stable in v23.5.0 and v22.13.0. |
|
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. |
|
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. |
|
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.
| Threat (guide section) | CWE | Core question | Section on this page |
|---|---|---|---|
Denial of service of the HTTP server |
Can slow, broken or numerous clients exhaust sockets, memory or the event loop? |
||
DNS rebinding |
Can a web page reach a service (typically the inspector) bound to localhost? |
||
Exposure of sensitive information to an unauthorized actor |
Does |
||
HTTP request smuggling |
Do a proxy and Node.js disagree about where a request ends? |
||
Information exposure through timing attacks |
Does the time to answer reveal how much of a secret was right? |
||
Malicious third-party modules |
What happens when a dependency, direct or transitive, turns hostile? |
Malicious third-party modules and the supply chain (CWE-1357) |
|
Memory access violation |
Can secrets leak from process memory on a shared machine? |
||
Monkey patching |
Can code replace built-in behaviour at run time? |
||
Prototype pollution |
Can user input add properties to |
||
Uncontrolled search path element |
Does module resolution pick up a file nobody intended to load? |
||
Node.js Permission Model |
(complement to the list) |
Can the process be limited to what it needs? |
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.
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');
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.
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();
});
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.
// 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);
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.
{
"name": "@example/bookshelf-lib",
"version": "1.0.0",
"type": "module",
"exports": "./dist/index.js",
"files": ["dist"]
}
Source: npm Docs: package.json, files
npm pack --dry-run # or: npm publish --dry-run
npm notice Tarball Contents
npm notice 20B dist/index.js
npm notice 134B package.json
npm notice total files: 2
Source: Node.js Learn: Security Best Practices, Exposure of Sensitive Information, npm Docs: npm publish
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.
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));
strict parser : HTTP/1.1 400 Bad Request
lenient parser: HTTP/1.1 200 OK
Source: Node.js Learn: Security Best Practices, HTTP Request Smuggling, Node.js API: CLI, --insecure-http-parser
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.
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); }
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
Source: Node.js Learn: Security Best Practices, Information Exposure through Timing Attacks, Node.js API: crypto.timingSafeEqual(), Node.js API: crypto.scrypt() and OWASP Password Storage Cheat Sheet
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.
| 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 |
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 ( |
Lockfile poisoning |
A pull request edits |
Review lockfile diffs like code; install with |
Malicious package, new or old |
A package is published with hostile code from day one, or an old version is found to be hostile. |
|
Install-time scripts |
|
|
Source: Node.js Learn: Security Best Practices, Malicious Third-Party Modules, npm Docs: Threats and Mitigations
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).
min-release-age=3
save-exact=true
audit-level=high
npm ci
npm audit --audit-level=high
npm audit signatures
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)
Source: npm Docs: npm ci, npm Docs: npm audit
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.
| npm | Observed behaviour |
|---|---|
11.19.1 (bundled with Node.js 26 and 24) |
Installs and prints |
12.2.0 (not bundled) |
Installs the package but prints |
12.2.0 with |
Writes a pinned entry to |
12.2.0 with |
Fails with |
{
"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.
import { secureHeapUsed, generateKeyPairSync } from 'node:crypto';
console.log('before:', secureHeapUsed());
generateKeyPairSync('ed25519');
console.log('after: ', secureHeapUsed());
$ 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.
// 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);
$ 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
Source: Node.js Learn: Security Best Practices, Monkey Patching, Node.js API: CLI, --frozen-intrinsics
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.
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));
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__.
// 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
$ node --disable-proto=throw proto.mjs
true
ERR_PROTO_ACCESS
undefined
$ node --disable-proto=delete proto.mjs
true
undefined
undefined
Source: Node.js API: CLI
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.
// 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';
auth (extensionless file, planted)
<dir>/auth
auth.js (intended)
auth.js
Source: Node.js Learn: Security Best Practices, Uncontrolled Search Path Element, Node.js API: Modules, CommonJS modules
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.
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);
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' } ]
Source: Node.js API: child_process.execFile(), Node.js API: Deprecations, DEP0190, Node.js API: SQLite
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); }
}
/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.
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); }
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:
// 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'));
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.
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); }
node --permission --allow-fs-read="$PWD/perm.mjs" perm.mjs
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.
| Flag | What it does | Notes |
|---|---|---|
|
Deny by default and grant by capability. |
Stable (v23.5.0, v22.13.0); |
|
Freezes built-in objects against monkey patching. |
1 Experimental; may break code; run |
|
Removes or traps |
Added v13.12.0, v12.17.0; may break old libraries. |
|
OpenSSL secure heap for key material. |
Power of two; fixed size; unavailable on Windows. |
|
|
Does not affect |
|
A |
v23.7.0, v22.14.0; not experimental since v24.8.0, v22.20.0. Never expose |
|
Disables loading native addons and the |
v16.10.0, v14.19.0. |
|
Does not search |
v16.10.0. |
|
Caps the HTTP header size (default 16 KiB). |
Default raised from 8 KiB to 16 KiB in v13.13.0. Prefer the per-server |
|
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. |
|
Hides extra information when a fatal exception ends the process. |
v17.0.0. |
|
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.
| 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. |
Source: Node.js: Security Reporting
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.
| Release | Selected items (severity as announced) | Relevance to this page |
|---|---|---|
January 2026 (async_hooks mitigation post) |
A stack overflow while |
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 |
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 |
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. |
Source: Node.js Blog: Vulnerabilities, March 2026 Security Releases, June 2026 Security Releases, July 2026 Security Releases, January 2026 DoS mitigation post
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.
| 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. |
|
Process flags |
|
Hardening flags at a glance, The Permission Model: a seat belt, not a sandbox |
HTTP server |
Timeouts and limits, |
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 |
Injection: commands, SQL and paths, Prototype pollution (CWE-1321) |
Secrets |
Environment variables loaded with |
Information exposure through timing attacks (CWE-208), Exposure of sensitive information (CWE-552) |
Dependencies |
Committed lockfile, |
Malicious third-party modules and the supply chain (CWE-1357) |
Publishing |
|
Exposure of sensitive information (CWE-552), Malicious third-party modules and the supply chain (CWE-1357) |
Pitfalls
| Mistake | Better |
|---|---|
Treating |
Use a separate process or container with no secrets and no network; see The vm module is not a sandbox. |
Comparing secrets with |
Hash both sides to the same length and compare digests with |
Adding |
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 |
Running |
Run |
Allowing every install script because one package needs it |
On npm 12 approve the single package with a pinned version; on older npm run |
Setting |
Add the package to |
Starting the service with |
Keep the inspector on localhost and tunnel to it; add |
Enabling |
Fix the client or the proxy; leave the parser strict. |
Merging request bodies into configuration with a recursive helper |
Validate with a schema, skip |
Publishing from a directory that contains |
Add |
Using |
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
| Version or date | Change |
|---|---|
Node.js 20.0.0 |
The Permission Model ( |
Node.js 22.15.0, 23.11.0, 24.0.0 |
DEP0190: an |
Node.js 24.0.0 |
|
Node.js 22.20.0 and 24.8.0 |
|
Node.js 24.7.0 |
|
Node.js 25.0.0 |
|
npm 11.10.0 (2026-02-11) |
|
npm 12.0.0 (2026-07-08) |
Dependency install scripts are blocked unless approved in |
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 |
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.
| Topic | Current state |
|---|---|
Supply chain |
Neither book covers |
Permission Model |
Efficient Node.js describes it as something being introduced; it is stable ( |
Child processes |
Passing an argument array together with |
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 ( |
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
-
Node.js Learn: Security Best Practices, https://nodejs.org/learn/getting-started/security-best-practices
-
Node.js: Security Reporting, https://nodejs.org/en/about/security-reporting
-
Node.js repository: SECURITY.md (policy and threat model), https://github.com/nodejs/node/blob/main/SECURITY.md
-
Node.js Blog: Vulnerabilities, https://nodejs.org/en/blog/vulnerability
-
Node.js Blog: March 2026 Security Releases, https://nodejs.org/en/blog/vulnerability/march-2026-security-releases
-
Node.js Blog: June 2026 Security Releases, https://nodejs.org/en/blog/vulnerability/june-2026-security-releases
-
Node.js Blog: July 2026 Security Releases, https://nodejs.org/en/blog/vulnerability/july-2026-security-releases
-
Node.js Blog: January 2026 DoS mitigation (async_hooks), https://nodejs.org/en/blog/vulnerability/january-2026-dos-mitigation-async-hooks
-
Node.js HackerOne program, https://hackerone.com/nodejs
-
Node.js: Previous releases, https://nodejs.org/en/about/previous-releases
-
Node.js Learn: Debugging Node.js, https://nodejs.org/learn/getting-started/debugging
-
Node.js API: Command-line API, https://nodejs.org/docs/latest/api/cli.html
-
Node.js API: Permissions, https://nodejs.org/docs/latest/api/permissions.html
-
Node.js API: Crypto, https://nodejs.org/docs/latest/api/crypto.html
-
Node.js API: Crypto, crypto.timingSafeEqual(), https://nodejs.org/docs/latest/api/crypto.html#cryptotimingsafeequala-b
-
Node.js API: Crypto, crypto.scrypt(), https://nodejs.org/docs/latest/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
-
Node.js API: Crypto, crypto.secureHeapUsed(), https://nodejs.org/docs/latest/api/crypto.html#cryptosecureheapused
-
Node.js API: HTTP, https://nodejs.org/docs/latest/api/http.html
-
Node.js API: Modules, https://nodejs.org/docs/latest/api/modules.html
-
Node.js API: Child process, https://nodejs.org/docs/latest/api/child_process.html
-
Node.js API: Deprecations, DEP0190, https://nodejs.org/docs/latest/api/deprecations.html#dep0190-passing-args-to-nodechild_process-execfilespawn-with-shell-option
-
Node.js API: SQLite, https://nodejs.org/docs/latest/api/sqlite.html
-
Node.js API: VM, https://nodejs.org/docs/latest/api/vm.html
-
Node.js API: Path, https://nodejs.org/docs/latest/api/path.html
-
npm Docs: Threats and Mitigations, https://docs.npmjs.com/threats-and-mitigations/
-
OWASP Cheat Sheet Series: Password Storage, https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html
-
npm Docs: npm ci, https://docs.npmjs.com/cli/v12/commands/npm-ci
-
npm Docs: npm audit, https://docs.npmjs.com/cli/v12/commands/npm-audit
-
npm Docs: npm approve-scripts, https://docs.npmjs.com/cli/v12/commands/npm-approve-scripts
-
npm Docs: npm deny-scripts, https://docs.npmjs.com/cli/v12/commands/npm-deny-scripts
-
npm Docs: Config, https://docs.npmjs.com/cli/v12/using-npm/config
-
npm Docs: npmrc, https://docs.npmjs.com/cli/v12/configuring-npm/npmrc
-
npm Docs: package.json, https://docs.npmjs.com/cli/v12/configuring-npm/package-json
-
npm Docs: npm publish, https://docs.npmjs.com/cli/v12/commands/npm-publish
-
npm Docs: Trusted publishing, https://docs.npmjs.com/trusted-publishers/
-
npm Docs: Generating provenance statements, https://docs.npmjs.com/generating-provenance-statements/
-
npm Docs: About access tokens, https://docs.npmjs.com/about-access-tokens/
-
npm Docs: Reporting malware in an npm package, https://docs.npmjs.com/reporting-malware-in-an-npm-package
-
MITRE: CWE-400, https://cwe.mitre.org/data/definitions/400.html
-
MITRE: CWE-346, https://cwe.mitre.org/data/definitions/346.html
-
MITRE: CWE-552, https://cwe.mitre.org/data/definitions/552.html
-
MITRE: CWE-444, https://cwe.mitre.org/data/definitions/444.html
-
MITRE: CWE-208, https://cwe.mitre.org/data/definitions/208.html
-
MITRE: CWE-1357, https://cwe.mitre.org/data/definitions/1357.html
-
MITRE: CWE-284, https://cwe.mitre.org/data/definitions/284.html
-
MITRE: CWE-349, https://cwe.mitre.org/data/definitions/349.html
-
MITRE: CWE-1321, https://cwe.mitre.org/data/definitions/1321.html
-
MITRE: CWE-427, https://cwe.mitre.org/data/definitions/427.html
-
OpenSSF: Scorecard, https://scorecard.dev
-
OpenSSF: Best Practices Badge, https://www.bestpractices.dev
-
O’Reilly: Efficient Node.js, Samer Buna, https://www.oreilly.com/library/view/efficient-node-js/9781098145187
-
O’Reilly: Node.js Projects, Jonathan Wexler, https://www.oreilly.com/library/view/node-js-projects/9781098173135/