The Permission Model
Node.js 20 introduced an experimental Permission Model — a runtime capability system that restricts what a Node.js process can do. By default Node has unrestricted access to the filesystem, network, child processes, and environment variables. With the permission model, you explicitly grant only the permissions the process needs — a form of least-privilege sandboxing. If the process tries to access something not granted, it throws a ERR_ACCESS_DENIED error rather than silently succeeding.
Enabling the permission model
# Enable with --experimental-permission (Node 20+): node --experimental-permission server.js # Without any --allow-* flags, almost all access is denied. # Grant specific permissions: node --experimental-permission \ --allow-fs-read=/app \ --allow-fs-write=/app/logs \ --allow-net \ server.js
(node:12345) ExperimentalWarning: The Permission Model is an experimental feature and might change at any time Server listening on :3000
Available permission flags
Flag | What it grants |
|---|---|
| Read access to filesystem; omit path to allow all reads |
| Write access to filesystem; omit path to allow all writes |
| Outbound network connections; omit host to allow all |
| Spawn child processes (child_process.spawn, exec, fork) |
| Create Worker Threads |
| Load native addons (.node files) |
| Read environment variables; omit name to allow all |
| Use WASI (WebAssembly System Interface) |
Filesystem permission examples
# Allow reading only from /app and /etc/ssl (for TLS certs): node --experimental-permission \ --allow-fs-read=/app,/etc/ssl/certs \ server.js # Allow writing only to /app/logs and /tmp: node --experimental-permission \ --allow-fs-read=/app \ --allow-fs-write=/app/logs,/tmp \ server.js # Wildcard — allow read from all paths under /app: node --experimental-permission --allow-fs-read=/app/* server.js
// Programmatic permission check before accessing:
import fs from 'node:fs'
// Check if read access is granted before attempting:
if (process.permission.has('fs.read', '/etc/passwd')) {
const content = fs.readFileSync('/etc/passwd', 'utf8')
} else {
console.log('No permission to read /etc/passwd')
}
// Without the check, accessing a denied resource throws:
try {
fs.readFileSync('/etc/shadow')
} catch (err: any) {
console.log(err.code) // ERR_ACCESS_DENIED
}Network permissions
# Allow outbound connections only to your API and database: node --experimental-permission \ --allow-fs-read=/app \ --allow-net=api.example.com:443,db.internal:5432 \ server.js # Allow all outbound network (but still restrict filesystem): node --experimental-permission \ --allow-fs-read=/app \ --allow-fs-write=/app/logs \ --allow-net \ server.js
Environment variable restrictions
# Allow access to specific environment variables only: node --experimental-permission \ --allow-env=DATABASE_URL,PORT,NODE_ENV \ server.js # Process attempting to read an undeclared env var: # process.env.SECRET_KEY → throws ERR_ACCESS_DENIED
// Check which permissions are active at runtime:
console.log(process.permission.has('fs.read')) // true if --allow-fs-read given
console.log(process.permission.has('fs.read', '/tmp')) // true if /tmp is in the allow list
console.log(process.permission.has('net')) // true if --allow-net given
console.log(process.permission.has('child')) // true if --allow-child-process givenPractical use cases
Running user-provided scripts — restrict them to only read a sandboxed directory and make no network calls.
Defence-in-depth for microservices — a billing service should not be able to read the authentication service's key files, even if compromised.
Preventing supply-chain attacks — an npm package that tries to exfiltrate secrets via an outbound HTTP call is blocked if
--allow-netis scoped to your own APIs.CI/CD scripts — run build scripts that should only touch the project directory, not the rest of the filesystem.
Auditing existing code — run with
--experimental-permissionand no--allow-*flags to discover what your code actually accesses.