The Node.js Permission Model has graduated from experimental to stable in Node.js 26.3.
Learn how to use --permission, process.permission.has(), and the
new irreversible process.permission.drop() API to secure your applications.
For years, securing a Node.js server meant relying on OS-level sandboxing (seccomp, AppArmor,
containers) or third-party modules. The runtime itself had no built-in mechanism to say
"this process should be able to read /var/www but not /etc/passwd".
That changed with the Node.js Permission Model — a runtime security layer
that controls access to file system, network, child processes, worker threads, native addons,
WASI, FFI, and the V8 inspector. When enabled via the --permission flag, the
model follows a default-deny approach: grant individual capabilities explicitly.
With Node.js 26.3.0 (released June 1, 2026), the Permission Model reached
Stability 2 — Stable. The --experimental-permission flag was
deprecated; --permission is now a first-class, production-ready feature.
The Permission Model takes a "seat belt" approach: it prevents trusted code from unintentionally accessing resources that haven't been explicitly granted. It does not protect against malicious code — Node.js trusts any code it is asked to run, and the permission model cannot prevent a determined attacker from bypassing its restrictions at the OS level.
When you start Node.js with --permission, the following capabilities are
restricted by default:
node:fs modulechild_process, cluster)worker_threads)require('node-addon-api'))| Scope | Flag | Runtime Check |
|---|---|---|
| File System Read | --allow-fs-read |
process.permission.has('fs.read', path) |
| File System Write | --allow-fs-write |
process.permission.has('fs.write', path) |
| Network | --allow-net |
process.permission.has('net') |
| Child Process | --allow-child-process |
process.permission.has('child') |
| Worker Threads | --allow-worker |
process.permission.has('worker') |
| Native Addons | --allow-addons |
process.permission.has('addons') |
| WASI | --allow-wasi |
process.permission.has('wasi') |
| FFI | --allow-ffi |
process.permission.has('ffi') |
| Inspector | Disabled by --permission |
process.permission.has('inspector') |
The simplest invocation restricts everything:
$ 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'
}
Even reading the entrypoint file is denied! The model correctly identifies that no read permission was granted. To actually run your application, grant the required permissions explicitly:
$ node --permission \
--allow-fs-read=/home/user/project \
--allow-fs-write=/tmp \
app.js
By default, the entrypoint of your application is automatically included in the allowed
file system read list — so app.js will be readable even without an explicit
--allow-fs-read for it.
When the Permission Model is enabled, a new permission property is added to
the process object. It provides two methods for runtime permission management.
Checks whether a permission has been granted for a specific scope and optional resource:
process.permission.has('fs.write'); // true (write was granted)
process.permission.has('fs.write', '/tmp/log.txt'); // true (we allowed /tmp)
process.permission.has('fs.read', '/etc/passwd'); // false (not in allowed paths)
process.permission.has('child'); // false (child process not allowed)
process.permission.has('net'); // false (network not allowed)
This is useful for graceful degradation: instead of trying an operation
and catching ERR_ACCESS_DENIED, check permission first and provide a clear
error or alternative path.
This is the headline feature — an irreversible runtime API that permanently
revokes a permission. Once dropped, the permission cannot be re-granted in the same process
(there is no permission.grant() method, by design).
const fs = require('node:fs');
// Read config at startup while we still have permission
const config = fs.readFileSync('/etc/myapp/config.json', 'utf8');
// Permanently drop read access to /etc/myapp after initialization
process.permission.drop('fs.read', '/etc/myapp');
// This will now throw ERR_ACCESS_DENIED
process.permission.has('fs.read', '/etc/myapp/config.json'); // false
// Drop child process permission entirely
process.permission.drop('child');
// Network scope — drop everything
process.permission.drop('net');
process.permission.drop('fs.read') revokes all file system read access.
*),
only the entire scope can be dropped. You cannot target individual resources
within a wildcard grant.
--allow-fs-read=/my/folder), you must drop the same directory path,
not individual files inside it.
The real power of permission.drop() is the ability to implement the
principle of least privilege over time. A typical pattern:
// Phase 1: Initialization — needs broad access
const config = JSON.parse(fs.readFileSync('/etc/app/config.json', 'utf8'));
const db = require('better-sqlite3')('/var/lib/app/data.db');
process.permission.drop('fs.read', '/etc/app');
process.permission.drop('fs.write', '/var/lib/app');
// Phase 2: Request handling — needs network + limited file access
const server = require('http').createServer((req, res) => {
if (req.url === '/logs') {
const log = fs.readFileSync('/var/log/app/request.log', 'utf8');
res.end(log);
}
});
server.listen(3000);
// After some time, drop read access to logs too
process.permission.drop('fs.read', '/var/log/app');
// Phase 3: Only network remains — the process can respond but
// cannot read/write any files or spawn child processes.
The --allow-fs-read and --allow-fs-write flags accept several
forms of arguments:
* — Allow all FileSystemRead or FileSystemWrite operations.
--allow-fs-read=/home/test* allows everything
matching the pattern (/home/test/file1, /home/test2, etc.).
After a wildcard, all subsequent characters are ignored.
# Allow all file system reads
--allow-fs-read=*
# Allow reads to /tmp and /home/.gitignore specifically
--allow-fs-read=/tmp/ --allow-fs-read=/home/.gitignore
# Allow writes to /tmp/ folder
--allow-fs-write=/tmp/
# Allow reads matching a wildcard pattern
--allow-fs-read=/home/test*
When the Permission Model initializes, it automatically adds a wildcard (*)
if the specified directory exists. For example, if /home/test/files exists,
it is treated as /home/test/files/*. If the directory does not exist, the
wildcard is not added, and access is limited to the exact path. If you want to allow access
to a folder that does not exist yet, explicitly include the wildcard:
/my-path/folder-not-yet-exists/*.
In addition to command-line flags, permissions can be declared in a Node.js configuration
file using the --experimental-config-file flag. Create a file named
node.config.json:
{
"permission": {
"allow-fs-read": ["./foo", "./bar"],
"allow-fs-write": ["./tmp"],
"allow-child-process": true,
"allow-worker": true,
"allow-net": true,
"allow-addons": false,
"allow-ffi": false
}
}
$ node --experimental-default-config-file app.js
When the permission namespace is present in the configuration file, Node.js
automatically enables the --permission flag — you don't need to pass it
separately.
If you're running scripts via npx, you can enable the Permission Model using
the --node-options flag:
# Basic — enable permission model
npx --node-options="--permission" package-name
# With file system access for global modules
npx --node-options="--permission --allow-fs-read=$(npm prefix -g)" package-name
# With npx cache access
npx --node-options="--permission --allow-fs-read=$(npm config get cache)" package-name
The --node-options flag sets the NODE_OPTIONS environment variable
for all Node.js processes spawned by npx, without affecting the npx
process itself.
Begin with --permission and no --allow-* flags. Run your
application, observe which ERR_ACCESS_DENIED errors occur, and grant only
the required permissions. This ensures you never accidentally over-permission.
Many applications need broad permissions at startup (reading config files, opening database
connections) but almost none afterwards. Use process.permission.drop() after
initialization to permanently reduce your attack surface:
// Initialize application
initializeApp();
// Lock down: drop all file system write, child process, and network
process.permission.drop('fs.write');
process.permission.drop('child');
// Now this process can only read files and respond to requests
Before attempting a potentially restricted operation, check permission first:
if (process.permission.has('fs.write', '/tmp/cache')) {
fs.writeFileSync('/tmp/cache/data.json', JSON.stringify(data));
} else {
// Fall back to in-memory cache
inMemoryCache.set(key, data);
}
The Permission Model is not a replacement for OS-level security. Run your Node.js process in a container, use a non-root user, and consider seccomp/AppArmor profiles. The Permission Model layers on top of these — providing defense in depth at the application level where OS tools have no visibility.
Be aware of these important constraints:
Worker operates with its own permission state.
--env-file and
--openssl-config read files before the environment is set up and are
not subject to the Permission Model.
node:sqlite module can access the file
system independently of the node:fs restrictions.
Here's what a typical Express.js application looks like before and after adding the Permission Model:
$ node server.js
# The process has full access to:
# - Every file on the filesystem
# - All network interfaces
# - Child process spawning
# - Any native addon
# If compromised, the attacker owns the entire server
$ node --permission \
--allow-fs-read=/etc/app/config.json \
--allow-fs-read=/var/www \
--allow-fs-write=/var/log/app \
--allow-net \
server.js
const fs = require('node:fs');
const path = require('node:path');
// Phase 1: Read config and initialize
const config = JSON.parse(
fs.readFileSync('/etc/app/config.json', 'utf8')
);
// Drop config read access — no longer needed
process.permission.drop('fs.read', '/etc/app/config.json');
// Phase 2: Only /var/www (static files) and /var/log/app remain accessible
const express = require('express');
const app = express();
app.use(express.static('/var/www'));
app.post('/api/data', (req, res) => {
fs.writeFileSync('/var/log/app/requests.log', data);
res.json({ ok: true });
});
app.listen(3000);
The key difference: after the first process.permission.drop() call, even if
the application is compromised, the attacker cannot read /etc/app/config.json
to extract secrets — the permission has been irreversibly revoked.
Let's put it all together with a complete, production-style example of a secure API server using the Permission Model:
// secure-server.js
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
// === INITIALIZATION PHASE ===
// Load secrets from restricted paths
const dbConfig = JSON.parse(
fs.readFileSync('/secrets/database.json', 'utf8')
);
const apiKey = fs.readFileSync('/secrets/api-key.txt', 'utf8').trim();
// Drop ALL file system read access — secrets are loaded
process.permission.drop('fs.read');
// Drop ALL file system write access — no files should change
process.permission.drop('fs.write');
// Drop child process spawning — the server never needs it
process.permission.drop('child');
// Drop worker threads — not needed for this service
process.permission.drop('worker');
// === REQUEST HANDLING PHASE ===
// Only network permission remains
const server = http.createServer((req, res) => {
if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', permissions: 'locked' }));
return;
}
if (req.url === '/api/data') {
// Graceful degradation: check permission first
if (!process.permission.has('fs.read')) {
// But wait — we already dropped it!
// This demonstrates that the permission is truly gone
res.writeHead(403, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'file access not available' }));
return;
}
}
res.writeHead(200);
res.end('Hello from locked-down process');
});
server.listen(3000, () => {
console.log('Server running on port 3000');
});
This pattern — initialize, drop permissions, serve — is the practical embodiment of the principle of least privilege for server-side Node.js applications.
For a broader overview of all the new features in Node.js 26, check my Complete Node.js 26 Guide covering Temporal API, V8 14.6, and Undici 8.
For secure Node.js applications, see my development services
The Node.js Permission Model, now stable in Node.js 26.3, brings runtime security to the
JavaScript runtime in a way that's practical, composable, and easy to adopt incrementally.
The process.permission.drop() API — the ability to irreversibly revoke
permissions after initialization — is especially powerful for implementing the principle
of least privilege in long-running server processes.
If you're building Node.js applications and want to add proper security hardening, I can help you design a permission strategy for your specific architecture. Get in touch for a free consultation on your project's security posture.
I'm a full-stack developer with 20+ years of experience building and securing Node.js applications — from startups to enterprise platforms. Based in Minsk and working worldwide, let's discuss your project.
Need help hardening your Node.js application or migrating to Node.js 26 with the Permission Model? I provide free initial consultations.