Node.js 22 Permission Model in Practice — How to Minimize Process Privileges and Reduce Supply Chain Attack Surface with `--allow-fs-read`
Supply chain attacks in the npm ecosystem are on the rise. The moment a single package is compromised, it becomes hard to fully trust any of the hundreds of dependencies pulled in by npm install. Lock file management, dependency auditing, and npm audit are still valid — but a second line of defense for after a compromise occurs is also necessary.
The Node.js Permission Model is exactly that. Even if a malicious package executes inside the process, it blocks filesystem access at the runtime level, minimizing the blast radius. This article explores how to apply it in real services and what pitfalls to avoid.
First, Honestly: What the Permission Model Blocks and What It Doesn't
You need to understand this limitation clearly before adopting it. The Node.js Permission Model does not control network access. This is a critical difference between Deno and Node.js.
The supported control targets are as follows:
| Flag | Controls |
|---|---|
--allow-fs-read=<path> |
Filesystem reads |
--allow-fs-write=<path> |
Filesystem writes |
--allow-child-process |
Child process spawning |
--allow-worker |
Worker Threads |
--allow-addons |
Native addons |
--allow-wasi |
WASI instances |
Outbound HTTP/TCP is outside the Permission Model's control. Even if a compromised package attempts to send data to an external server, the Permission Model does not intervene. Network egress control must be supplemented with Kubernetes NetworkPolicy, service mesh egress policies, or container-level firewalls.
With that limitation in mind, the goal is to properly leverage the defenses the Permission Model provides in the filesystem and process permission domains.
Core Concepts
The Entry Flag and Opt-in Model
The entry point for the Permission Model is a single flag.
--experimental-permission— The name used in Node.js 20–22--permission— The renamed version added from Node.js 23.5.0
Specifying just this one flag blocks all controllable resource access by default — filesystem, child processes, worker threads, and more. You then open only the permissions you need with individual flags.
# For Node.js 22
node --experimental-permission \
--allow-fs-read=/app/src,/app/node_modules \
--allow-fs-write=/app/logs \
server.jsPaths support single files, directories, and wildcards, and multiple paths can be listed with commas. The Permission Model is opt-in, so running without the flag behaves exactly as before. The biggest practical advantage is that you can introduce it incrementally by adding only the flag, with no code changes.
The flow when a file access is attempted while the model is active:
flowchart TD
A[File access attempted] --> B{Permission Model active?}
B -->|Inactive| C[Access allowed]
B -->|Active| D{Check allowed path list}
D -->|Included| E[Access granted]
D -->|Not included| F[ERR_ACCESS_DENIED raised]
F --> G[Malicious code contained]process.permission.has() — Runtime Permission Check
You can directly check the current process's permission state from within your code. This is useful not just for surfacing errors early, but for branching behavior based on permissions or providing fallbacks.
process.permission.has('fs.write'); // true/false
process.permission.has('fs.read', '/etc/passwd'); // false
process.permission.has('fs.read', '/app/config'); // true (if it's an allowed path)
process.permission.has('child'); // true/falseA practical usage pattern is graceful fallback rather than throwing an error:
import { createReadStream } from 'fs';
function readOrFallback(filePath, fallbackData) {
if (!process.permission.has('fs.read', filePath)) {
// Silently substitute with default value if no permission
return fallbackData;
}
return createReadStream(filePath);
}Real-World Application
Scenario 1: Running an Express API Server with Least Privilege
The most common case. Explicitly declare the paths the API server needs to read and write.
node --experimental-permission \
--allow-fs-read=/app \
--allow-fs-write=/app/tmp,/app/logs \
index.jsWith this configuration, even if a compromised package inside node_modules tries to read /etc/passwd or .env, it will be blocked with ERR_ACCESS_DENIED. Remember that the Permission Model does not intervene for outbound HTTP requests, so network-level control must be configured separately at the infrastructure level.
Scenario 2: Data Processing Batch Scripts
ETL jobs or migration scripts that don't need network access are the easiest targets for adopting the Permission Model. There are only two access paths — input and output — so building the allow list is straightforward.
node --experimental-permission \
--allow-fs-read=/data/input \
--allow-fs-write=/data/output \
etl-job.jsFor workloads that inherently don't need network egress, the absence of a flag like --allow-net is actually an advantage. Block the outbound connections for that process at the infrastructure firewall, and the data exfiltration path simply disappears.
Scenario 3: Defense-in-Depth in a Container Environment
In Docker/Kubernetes, combining container-level security with the Permission Model provides double coverage at two different points.
CMD ["node", "--experimental-permission", "--allow-fs-read=/app", "--allow-fs-write=/tmp", "server.js"]In JSON array format (CMD [...]), the line continuation character \ cannot be used. When using multiple flags, list them on a single line or use shell form.
# kubernetes securityContext
securityContext:
readOnlyRootFilesystem: true
allowPrivilegeEscalation: falseHere is a summary of what threat each defense layer blocks and at what point:
Install time and runtime are separate stages. Even if the package manager level protection is breached, the runtime layer still operates; and even if the Node.js process level is bypassed, the container layer remains.
Scenario 4: Sandboxing Third-Party Plugins
When running low-trust third-party code as isolated Worker Threads, you control the worker creation itself via the main process's --allow-worker flag.
import { Worker } from 'worker_threads';
// If the main process lacks --allow-worker, this line throws ERR_ACCESS_DENIED
const worker = new Worker('./plugin.js', {
workerData: { inputPath: '/data/input' }
});
worker.on('error', (err) => {
if (err.code === 'ERR_ACCESS_DENIED') {
console.error('Plugin attempted to access a resource that is not allowed');
}
});The main process's Permission policy is not automatically inherited by workers. If you want to restrict a worker more narrowly, spawn the worker script as a separate node process and pass independent flags to that process.
Common Mistakes in Practice
Assuming permissions are inherited by child processes
Child processes spawned via child_process receive none of the parent's Permission policy.
import { spawn } from 'child_process';
// child.js runs independently of the parent's Permission policy
const child = spawn('node', ['child.js']);
// To apply it to the child as well, pass the flags directly
const safeChild = spawn('node', [
'--experimental-permission',
'--allow-fs-read=/child/data',
'child.js'
]);Placing symbolic links inside allowed paths
If you allow /app/data but there's a symbolic link like /app/data/link → /etc, a path to access locations outside the allowed range is created. Make it a habit to periodically audit symbolic links within allowed paths.
Misunderstanding the order of --env-file and the Permission Model
--env-file reads the file before the Permission Model is applied. Environment variable file loading is an initialization step that falls outside the Permission Model's protection.
Omitting access paths inside node_modules
If you don't identify all paths that packages access internally, the service won't start. The practical approach is to start broad — such as --allow-fs-read=/app — then gradually narrow it down by watching the ERR_ACCESS_DENIED logs.
Comparison with Deno
When comparing network egress control, the difference between Node.js and Deno becomes starkly clear.
| Item | Node.js Permission Model | Deno Permission Model |
|---|---|---|
| Filesystem read/write control | ✅ | ✅ |
| Network egress control | ❌ (infrastructure-level supplement required) | ✅ (--allow-net) |
| Child process control | ✅ | ✅ |
| Environment variable access control | ❌ | ✅ |
| Default security mode | Opt-in | Secure-by-default |
| Existing npm ecosystem compatibility | ✅ | Partial |
Deno was designed as secure-by-default from the start — nothing is permitted unless explicitly allowed. Node.js chose opt-in for compatibility with the existing ecosystem, and has the practical advantage of being adoptable with just a flag and no code changes. However, to extend runtime sandboxing beyond the filesystem, infrastructure-level controls become a mandatory complement.
Closing Thoughts
The Node.js Permission Model is not a tool to prevent compromise — it's a tool to limit damage after compromise. Even if a malicious package is already executing inside the process, restricting filesystem access can block sensitive file theft and unintended writes.
Since it cannot block network egress, it only provides meaningful layered defense when used alongside Kubernetes NetworkPolicy and container security policies. Because each layer blocks a different set of threats, there is a structural advantage: even if one layer is bypassed, the next layer remains.
If you're thinking about where to start:
- Start with batch scripts — ETL jobs and migration scripts have simple access paths, making it easiest to build the allow list, and they carry lower risk than service code.
- Collect
ERR_ACCESS_DENIEDerrors in the development environment — If applying directly to production is difficult, enable the flag in the development environment, collect the errors that occur, and gradually complete the allow list. - Pair with
readOnlyRootFilesystemin container deployments — In a Kubernetes environment, applysecurityContext.readOnlyRootFilesystem: truetogether with the Permission flag in CMD to secure both layers at once.
References
- Permissions | Node.js v22.15.0 Official Docs
- Permissions | Node.js v26.5.0 Official Docs
- Node.js 22 Permission Model: Deep Dive & Benchmarks
- Enable Node.js Permission Model | Learn Node.js Security
- 5 Node.js Permission Model Changes Every API Developer Should Know in 2026 - DEV Community
- Hardening Your Node.js App Against Supply Chain & RCE Attacks - DEV Community
- Secure Node.js Applications from Supply Chain Attacks | Auth0 Blog
- Node.js Security Best Practices for 2026 | Medium
- How Deno protects against npm exploits | Deno Official Blog
- Node.js June 2026 Security Releases
- nodejs/node permissions.md — GitHub