The Permission Model

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and every example below was run on all three lines unless the text says otherwise. The permission model lets you start a Node.js process with most of its authority switched off, and then hand back only the capabilities the program needs: reading one folder, writing one folder, nothing else. The model as a whole (--permission, the --allow-fs-read and --allow-fs-write flags, process.permission) is Stability 2 (Stable) since v23.5.0 and v22.13.0. The other --allow-* flags are still Stability 1.1 (Active development), --allow-inspector is Stability 1.0 (Early development), and the configuration file that can carry the same settings is Stability 1.2 (Release candidate) on Node.js 26 but 1.0 (Early development) on Node.js 24 and 22. The model is deliberately limited: the official documentation describes it as a "seat belt" for trusted code, not a sandbox, and the second half of this page is about exactly where that line is. The running scenario is the Bookshelf application described in Introduction & Architecture; the broader security checklist (threat model, supply chain, injection) is in Security Best Practices.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

Permission model, --permission, --allow-fs-read, --allow-fs-write

2 Stable

Added in v20.0.0 as --experimental-permission; no longer experimental in v23.5.0 and v22.13.0. Node.js 22 still lists --experimental-permission as an alias (observed in node --help).

process.permission.has()

Stable (part of the model)

Available whenever the model is on, on all three lines.

process.permission.drop()

Not labelled separately in the docs

Observed: present on Node.js 26.10.0 and 24.21.0, undefined on 22.23.3. The docs for 22 do not describe it.

--allow-child-process, --allow-worker, --allow-addons, --allow-wasi

1.1 Active development

Added v20.0.0, v20.0.0, v21.6.0 and v22.3.0 respectively (see the timeline below for the 20-line backports).

--allow-inspector

1.0 Early development

Added in v25.0.0 and v24.12.0.

--allow-net

1.1 Active development

Added in v25.0.0 only. Not available on Node.js 24 or 22 (bad option, observed).

--allow-ffi

1.1 Active development

Added in v26.1.0. Needs a build that includes node:ffi.

--allow-openssl-store

1.1 Active development

Added in v26.7.0, and v24.21.0 on the 24 line.

--allow-fs-vfs

1.1 Active development

Added in v26.9.0, only meaningful together with the experimental virtual file system.

--permission-audit

Not labelled separately in the docs

Added in v25.8.0 and v24.20.0. Not available on Node.js 22 (bad option, observed).

Configuration file "permission" block (--experimental-config-file)

1.2 Release candidate on 26 (since v26.7.0)

1.0 Early development on 24 and 22. The file mechanism was added in v23.10.0 and v22.16.0.

What it is, and what it is not

Normally a Node.js process can do anything its operating-system user can do: read any file, open any socket, spawn any program. The permission model flips that default for the Node.js APIs: once --permission is on, each class of operation is refused with an ERR_ACCESS_DENIED error unless a matching --allow-* flag was given at start-up. The point is to stop trusted code (yours, your dependencies on a good day) from accidentally touching things it was never meant to touch: a build script that writes outside its output folder, a report generator that opens a network socket, a dependency that starts a shell. That is the "seat belt" the official documentation talks about.

It is not a sandbox. The documentation is explicit that the feature does not protect against malicious code, because Node.js trusts any code it is asked to run, and that malicious code can bypass the model and execute arbitrary code outside its restrictions. Treat it as one layer of defence in depth next to operating-system isolation (separate users, containers, seccomp or AppArmor profiles) and supply-chain hygiene, not as a replacement for them. The limits are listed with working demonstrations in Where the seat belt ends.

Table 2. Seat belt versus sandbox
Permission model (seat belt) Sandbox for untrusted code

Threat it addresses

Honest mistakes and over-broad access by code you chose to run

Code written by an attacker

Enforced by

Checks inside the Node.js runtime, on Node.js APIs

The operating system or a hypervisor, outside the process

Trusted computing base

The runtime and every API that forgot a check (see the limits below)

A small, hardened boundary

Typical use

Scripts, CLIs and build steps that need a tiny, known set of capabilities; auditing what a program really touches

Running plug-ins, user-submitted code or third-party jobs

Turning it on: deny by default

node --permission app.js is all it takes. Every restricted capability is off until granted. The first example probes four things (read a file, write a file, spawn a child process, start a worker thread) with and without grants. It assumes a folder app/ that contains probe.mjs, a data/books.json file and an empty out/ folder.

probe.mjs: report what the process is allowed to do
import { readFileSync, writeFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { Worker } from 'node:worker_threads';

function attempt(label, fn) {
  try {
    fn();
    console.log(`${label}: allowed`);
  } catch (err) {
    console.log(`${label}: ${err.code} (${err.permission ?? 'n/a'})`);
  }
}

attempt('read data/books.json', () => readFileSync(new URL('./data/books.json', import.meta.url)));
attempt('write out/report.txt', () => writeFileSync(new URL('./out/report.txt', import.meta.url), 'ok'));
attempt('spawn child', () => spawn(process.execPath, ['-e', '0']).on('error', () => {}));
attempt('start worker', () => new Worker('0', { eval: true }));
Run with no grants, then with read access to data/ and write access to out/
node --permission probe.mjs
node --permission --allow-fs-read="$PWD/data" --allow-fs-write="$PWD/out" probe.mjs

The output below is identical on Node.js 26.10.0, 24.21.0 and 22.23.3 (observed). The entry point itself is always readable, which is why the script can start without any --allow-fs-read; that implicit read of the entry point, and of --require modules, is documented from v24.2.0 and v22.17.0.

Output without grants, then with them
read data/books.json: ERR_ACCESS_DENIED (FileSystemRead)
write out/report.txt: ERR_ACCESS_DENIED (FileSystemWrite)
spawn child: ERR_ACCESS_DENIED (ChildProcess)
start worker: ERR_ACCESS_DENIED (WorkerThreads)
read data/books.json: allowed
write out/report.txt: allowed
spawn child: ERR_ACCESS_DENIED (ChildProcess)
start worker: ERR_ACCESS_DENIED (WorkerThreads)

The error carries two useful properties: code (always ERR_ACCESS_DENIED) and permission, which names the denied class (FileSystemRead, FileSystemWrite, ChildProcess, WorkerThreads, Net, Inspector, WASI, FFI); file system errors also set resource to the path. Catch on err.code, log err.permission, and the denial is easy to diagnose. A module that fails this way at start-up (for example an ES module that imports a file you did not grant) fails with the same error before any of your code runs.

The flags

Table 3. Every permission flag, its runtime scope and error name
Flag Scope for permission.has() err.permission What it gates

--allow-fs-read=<path>

fs.read

FileSystemRead

Reading files and directories through node:fs (and the module loader).

--allow-fs-write=<path>

fs.write

FileSystemWrite

Creating, writing, renaming and deleting through node:fs.

--allow-child-process

child

ChildProcess

node:child_process (spawn, exec, fork and their sync forms).

--allow-worker

worker

WorkerThreads

Creating node:worker_threads workers.

--allow-addons

addon

none (code ERR_DLOPEN_DISABLED)

Loading native addons (.node files).

--allow-wasi

wasi

WASI

Creating node:wasi instances.

--allow-net

net

Net

Network access (observed on 26 only; see below).

--allow-inspector

inspector

Inspector

Connecting through the inspector protocol (node:inspector).

--allow-ffi

ffi

FFI

node:ffi dynamic libraries.

--allow-openssl-store

(not listed in the has() documentation)

ERR_ACCESS_DENIED

OpenSSL STORE loaders, for example loading a key from a URL passed to crypto.createPrivateKey().

--allow-fs-vfs

(not listed in the has() documentation)

ERR_INVALID_STATE from vfs.mount()

Mounting a virtual file system (experimental).

Table 4. Which lines accept which flag (observed with node --permission <flag> -e 0)
Flag Node.js 26.10.0 Node.js 24.21.0 Node.js 22.23.3

--allow-fs-read, --allow-fs-write, --allow-child-process, --allow-worker, --allow-addons, --allow-wasi

yes

yes

yes

--allow-inspector

yes

yes

bad option

--allow-openssl-store

yes

yes

bad option

--allow-net, --allow-ffi, --allow-fs-vfs

yes

bad option

bad option

--permission-audit

yes

yes

bad option

A few behaviours of the flags themselves are worth knowing before you rely on them:

  • Passing --permission together with --allow-child-process, --allow-worker, --allow-addons, --allow-ffi, --allow-inspector or --allow-openssl-store prints a SecurityWarning ("must be used with extreme caution. It could invalidate the permission model"). Observed codes on 26 and 24 are PERM0001 (addons), PERM0003 (FFI), PERM0004 (inspector), PERM0006 (worker) and PERM0007 (OpenSSL STORE); Node.js 22 prints the same message without a code. --allow-net prints an ExperimentalWarning instead.

  • The permission model is initialised after the Node.js environment is set up. Flags that read files early, such as --env-file and --openssl-config, run before it and are not subject to the rules, and neither are V8 flags changed later with v8.setFlagsFromString().

  • A worker thread does not inherit the model: the documentation says so under its constraints, which is one reason --allow-worker carries a warning.

File system grants

--allow-fs-read and --allow-fs-write take a path, may be repeated, and accept three forms: (everything), an absolute path, or a path relative to the current working directory. Comma-separated lists stopped working in v20.7.0; passing one prints a warning, so use one flag per path. A path ending in a wildcard matches by prefix, and every character after the first is ignored (/home/.js behaves like /home/). If the path names a directory that exists when the process starts, Node.js appends the wildcard for you, so --allow-fs-read=./data grants the folder and everything below it; a folder that does not exist yet must be written with an explicit / (--allow-fs-write=./cache/) to be usable once it is created.

Table 5. File system grant forms
Flag Meaning

--allow-fs-read=*

Every read is allowed. Useful with --allow-fs-write narrowed, or to start with the audit workflow below.

--allow-fs-read=./data

The data folder (it exists, so the wildcard is implied) and everything below it.

--allow-fs-read=./data --allow-fs-read=./config.json

Two grants: a folder and a single file.

--allow-fs-write=./cache/*

A folder that may not exist yet; the explicit wildcard is required.

--allow-fs-read=/home/test*

Prefix match: /home/test/file1 and /home/test2 both match.

Two sharp edges are documented. First, some node:fs operations act on an already-open file descriptor rather than a path (fsync, fdatasync, fchmod, fchown and their synchronous variants, plus the equivalent FileHandle methods). They cannot be tied to a path grant, so with the model on they are disabled and throw ERR_ACCESS_DENIED whatever the descriptor’s origin. Second, the constraints section also warns that using existing file descriptors through node:fs bypasses the model. Both are consequences of the same fact: the check happens where a path enters the API, and a descriptor has no path.

Child processes, workers, addons, WASI, network, inspector and FFI

Each remaining flag is an all-or-nothing switch for one subsystem. The examples below show what a denial looks like; every one of them was run.

others.mjs: the denied forms of the inspector, WASI and addon switches
import { Session } from 'node:inspector';
import { WASI } from 'node:wasi';
import { createRequire } from 'node:module';

function attempt(label, fn) {
  try { fn(); console.log(`${label}: allowed`); }
  catch (err) { console.log(`${label}: ${err.code} (${err.permission ?? 'n/a'})`); }
}
attempt('inspector connect', () => new Session().connect());
attempt('WASI instance', () => new WASI({ version: 'preview1' }));
attempt('load addon', () => createRequire(import.meta.url)('./fake.node'));

With fake.node an empty file that is readable (--allow-fs-read="$PWD/fake.node"), ERR_DLOPEN_DISABLED is the observed result on all three lines; adding --allow-addons changes it to ERR_DLOPEN_FAILED (the file is not a real addon), proving that the permission check is passed. The inspector and WASI attempts report ERR_ACCESS_DENIED with Inspector and WASI on all three lines (observed; for inspector on Node.js 22 this works even though the --allow-inspector flag does not exist there).

  • Child processes (--allow-child-process). Since v24.4.0 and v22.18.0 a permitted child_process.fork() passes the model’s flags to the child automatically, and spawn() passes them through the NODE_OPTIONS environment variable, so a Node.js child is as restricted as its parent. A child that is not Node.js is not restricted by anything: see Where the seat belt ends. More detail on the APIs is in Child Processes.

  • Worker threads (--allow-worker). Without it, new Worker() throws. With it, the documentation notes that the model does not inherit to the worker. See Worker Threads.

  • Native addons (--allow-addons). Without it, require() of a .node file throws ERR_DLOPEN_DISABLED; this is why a dependency with a native module breaks the first time you turn the model on.

  • WASI (--allow-wasi). Creating a WASI instance is refused in the main process unless this flag is given. A WASI guest that gets preopened directories reaches the real file system, which is the reason the flag exists.

  • Network (--allow-net, Node.js 26 only). The check is applied when the socket is created or bound. Observed on 26.10.0: a client net.connect() does not throw; the socket emits an 'error' event with code ERR_ACCESS_DENIED (and no permission property), and server.listen() throws from the bind. On Node.js 24 and 22 the same program connects normally, because there is no network scope: --permission does not restrict sockets on those lines (observed). The documentation shows --allow-net only as a plain switch, with no host or port argument.

  • Inspector (--allow-inspector). Stops the process connecting through the inspector protocol and, as the documentation puts it, the SIGUSR1 listener is not created. It does not stop another process from being asked to open its inspector; see the limits.

  • FFI (--allow-ffi, Node.js 26). node:ffi is Experimental and only exists in builds with FFI support; observed on 26.10.0, creating a DynamicLibrary under --permission fails with ERR_ACCESS_DENIED and permission FFI.

Network denial on Node.js 26 (net.mjs)
import net from 'node:net';
const sock = net.connect(9, '127.0.0.1');
sock.on('error', (e) => console.log(e.code, '|', e.permission, '|', e.message));
Output of node --permission net.mjs on 26.10.0, and with --allow-net (nothing is listening on port 9)
ERR_ACCESS_DENIED | undefined | connect ERR_ACCESS_DENIED Access to this API has been restricted. Use --allow-net to manage permissions.
ECONNREFUSED | undefined | connect ECONNREFUSED 127.0.0.1:9

process.permission: has() and drop()

When the model is enabled (by --permission or --permission-audit), the process object gains a permission property. Without the flag it is undefined, so feature-detect with process.permission?.has(…​).

permission.has(scope[, reference]) answers "may I do this?" without trying it. The scopes are fs.read, fs.write, child, worker, net, inspector, wasi, addon and ffi. With a reference (a path) it asks about one resource. Observed: without one, fs.read and fs.write print false when only a folder was granted, so the scope-only form behaves as "is everything allowed?".

has.mjs: query the grants
const scopes = ['fs.read', 'fs.write', 'child', 'worker', 'net', 'inspector', 'wasi', 'addon', 'ffi'];
for (const scope of scopes) {
  console.log(scope.padEnd(10), process.permission.has(scope));
}
console.log('fs.read data :', process.permission.has('fs.read', new URL('./data/books.json', import.meta.url).pathname));
console.log('fs.write out :', process.permission.has('fs.write', new URL('./out/', import.meta.url).pathname));
console.log('drop typeof  :', typeof process.permission.drop);
Output of node --permission --allow-fs-read="$PWD/data" --allow-fs-write="$PWD/out" --allow-worker has.mjs (Node.js 26.10.0)
fs.read    false
fs.write   false
child      false
worker     true
net        false
inspector  false
wasi       false
addon      false
ffi        false
fs.read data : true
fs.write out : true
drop typeof  : function

Node.js 24.21.0 prints the same lines. On 22.23.3 the output is identical except drop typeof : undefined, and (observed) asking about net, ffi or an invented scope returns false instead of throwing, so a false there does not prove the scope exists.

permission.drop(scope[, reference]) revokes a permission at run time and is irreversible. The pattern it enables is "read your configuration at start-up, then close the door": load what you need while you still have access, drop it, and a later bug or a compromised dependency cannot read it again. Three details from the documentation matter. Dropping only affects future checks and does not close anything already open (file descriptors, sockets, child processes, workers). You can only drop what was granted exactly: a folder grant is dropped by naming that folder, not a file inside it, and a * grant is dropped only without a reference. And dropping without a reference removes the whole scope.

drop.mjs: read at start-up, then drop
import { readFileSync } from 'node:fs';
const file = new URL('./data/books.json', import.meta.url);
const dataDir = new URL('./data/', import.meta.url).pathname;

const books = JSON.parse(readFileSync(file, 'utf8'));          // startup read
console.log('before drop:', process.permission.has('fs.read', dataDir));
process.permission.drop('fs.read', dataDir);                    // irreversible
console.log('after drop :', process.permission.has('fs.read', dataDir));
try {
  readFileSync(file);
} catch (err) {
  console.log('read after drop:', err.code);
}
process.permission.drop('worker');                              // drop a whole scope
console.log('title:', books.title);
Output of node --permission --allow-fs-read="$PWD/data" --allow-worker drop.mjs (identical on 26.10.0 and 24.21.0)
before drop: true
after drop : false
read after drop: ERR_ACCESS_DENIED
title: Dune

On Node.js 22.23.3 the call fails with a TypeError because drop does not exist. Observed, not documented: dropping a path that is only inside a granted folder does nothing and has() stays true, so do not assume a narrower drop worked; check with has().

Audit mode: find out what you need

Writing a correct --allow- list by hand is tedious and easy to get wrong. --permission-audit (Node.js 26 and 24.20+) runs the program with the checks in place but without enforcing them: nothing throws, and each violation is published on a node:diagnostics_channel channel instead. The flag does not need --permission and the --allow- flags are optional; you can combine them to audit only the part you have not yet granted. The channels are node:permission-model:fs, :net, :child, :worker, :inspector, :wasi, :addon and :ffi, and each message is an object with permission and resource.

audit.mjs: subscribe to every permission channel
import diagnosticsChannel from 'node:diagnostics_channel';
import { readFileSync, writeFileSync } from 'node:fs';

const scopes = ['fs', 'net', 'child', 'worker', 'inspector', 'wasi', 'addon', 'ffi'];
for (const scope of scopes) {
  diagnosticsChannel.subscribe(`node:permission-model:${scope}`, (msg) => {
    console.log(`[audit] ${msg.permission} -> ${msg.resource}`);
  });
}

// Neither call throws in audit mode; each one is reported instead.
const text = readFileSync(new URL('./data/books.json', import.meta.url), 'utf8');
writeFileSync(new URL('./out/audit.txt', import.meta.url), text);
console.log('done, bytes:', text.length);
Output of node --permission-audit audit.mjs (26.10.0 and 24.21.0; absolute paths shortened to <app>)
[audit] FileSystemRead -> <app>/data/books.json
[audit] FileSystemWrite -> <app>/out/audit.txt
done, bytes: 17

With --allow-fs-read=<app> added, only the write line remained (observed), which is the workflow: run the real workload (start-up, a request or two, the test suite) under --permission-audit, collect the distinct resource values per channel, turn them into --allow-* flags, then switch to --permission. A subscriber only sees what happens after it subscribes, so register it at the very top of the entry point (see Diagnostic Reports, Logging & Observability for the channel mechanism). On Node.js 22 the flag does not exist.

A discrepancy to be aware of, labelled observed: the documentation says that when both --permission and --permission-audit are given, --permission wins and the model enforces. On 26.10.0 and 24.21.0 the program that reads data/books.json without a grant ran to completion with both flags, in either order, behaving as audit mode. Do not rely on mixing the two; use one flag per run and verify with a denial test.

The configuration file

The same settings can live in a Node.js configuration file instead of a long command line. The file is JSON, named node.config.json by default, and permission is a top-level namespace next to nodeOptions, test and watch. The general mechanism (path forms, $schema, nodeVersion, precedence) is in CLI Options, Environment & Config File; here only the permission block matters.

node.config.json
{
  "permission": {
    "allow-fs-read": ["./data"],
    "allow-fs-write": ["./out"],
    "allow-child-process": false,
    "allow-worker": false
  }
}
Run it with the default file name
node --experimental-default-config-file probe.mjs

On Node.js 26.10.0 this printed an ExperimentalWarning for the config file and then exactly the second half of the earlier probe output (read and write allowed, child and worker denied), because, as the documentation says, the presence of the permission namespace turns --permission on by itself. The details, observed:

Table 6. Configuration file behaviour on the three lines
Line Observed with --experimental-default-config-file and the file above

Node.js 26.10.0

The permission block is applied and --permission is implied. A flag on the command line is added on top (--allow-worker re-enabled workers). Setting "permission": false inside the namespace to opt out fails with ERR_MISSING_OPTION, so use a separate file for runs without the model. A key that does not exist (allow-nope) is rejected with Unknown or not allowed option allow-nope for namespace permission.

Node.js 24.21.0

Fails with ERR_MISSING_OPTION: --permission is required unless --permission is also passed; with node --permission --experimental-default-config-file probe.mjs the grants apply. The 24 documentation shows exactly that command line.

Node.js 22.23.3

The permission block is not applied: without --permission every probe printed allowed, and with --permission the file’s grants were ignored (all four probes denied). Pass flags on the command line on this line.

The value types follow the flag: path flags take an array of strings, switches take booleans. Keys cannot repeat across namespaces, the parser rejects unknown keys, and the documentation warns that Node.js does not validate or sanitise the file, so never load an untrusted configuration file. Command-line options and NODE_OPTIONS take priority over the file.

Using the model with npx

npx itself must read the package it runs, so npx --node-options="--permission" some-package fails with a FileSystemRead denial. The documentation gives two fixes: for a globally installed package grant the global node_modules (npx --node-options="--permission --allow-fs-read=$(npm prefix -g)" some-package), and for the npx cache grant the cache directory (--allow-fs-read=$(npm config get cache)). The --node-options form sets NODE_OPTIONS for the processes npx spawns, not for npx itself.

Where the seat belt ends

The documentation lists constraints and known issues. Each row below is one of them, plus the two I demonstrated.

Table 7. Documented limits and what they mean in practice
Limit Consequence

Symbolic links are followed, even outside the granted paths; relative links can reach arbitrary files

Never grant a folder that contains relative symbolic links, and do not extract untrusted archives into a granted folder.

Other native code is not covered, for example node:sqlite

The permission page names node:sqlite as a way to reach the file system outside the node:fs checks; run-time loadable SQLite extensions are blocked, but opening a database is not.

Open file descriptors bypass the model

Descriptors passed in from outside (or inherited) keep their access.

Child processes that are not Node.js are not restricted

--allow-child-process hands the program the full authority of the OS user for whatever it starts.

--allow-worker, --allow-addons, --allow-wasi, --allow-ffi, --allow-openssl-store

Each warns that granting it can invalidate the model; the OpenSSL STORE loaders can reach files, devices, tokens and the network without being limited by the fs or net scopes.

process._debugProcess(pid) is not gated

A restricted process can signal another Node.js process of the same OS user to open its inspector. Use different OS users or OS isolation.

Early-reading flags (--env-file, --openssl-config) and V8 flags are outside the model

Anything controlled by the operator’s command line is trusted.

OpenSSL engines and sqlite run-time extensions cannot be requested while the model is on

A restriction, not a hole, but it breaks setups that depend on them.

Some operator-chosen output locations are not consistently checked

For example trace files rotated by --trace-event-file-pattern; the project treats such gaps as ordinary bugs, not vulnerabilities.

limits.mjs: a symlink and node:sqlite reach outside the grant
import { readFileSync } from 'node:fs';
import { DatabaseSync } from 'node:sqlite';

const dataDir = new URL('./data/', import.meta.url);

// 1. A symbolic link inside a granted folder points outside of it.
try {
  console.log('via symlink :', readFileSync(new URL('./link.txt', dataDir), 'utf8').trim());
} catch (err) {
  console.log('via symlink :', err.code);
}

// 2. node:sqlite opens the database file in native code, outside the fs checks.
try {
  const db = new DatabaseSync(new URL('../limits-demo.db', import.meta.url).pathname);
  db.exec('CREATE TABLE IF NOT EXISTS t (x)');
  console.log('via sqlite  : created a database file outside every grant');
  db.close();
} catch (err) {
  console.log('via sqlite  :', err.code);
}

With data/link.txt a symbolic link to a file outside data/ and node --permission --allow-fs-read="$PWD/data" limits.mjs, all three lines (26.10.0, 24.21.0, 22.23.3) printed the linked file’s contents and created the database file outside every grant (observed). The sqlite result is a good example of why "the model checks node:fs`" is not the same as "the process cannot touch the disk": a permission-model run that still imports `node:sqlite has an unchecked path to the file system.

escape.mjs: a non-Node child is not bound by the model
import { spawnSync } from 'node:child_process';

// A non-Node program is not bound by the Node.js permission model at all.
const r = spawnSync('sh', ['-c', 'echo escaped > escaped.txt && echo shell wrote a file'], { encoding: 'utf8' });
console.log(r.stdout.trim() || r.error?.code);

Run with node --permission --allow-fs-read="$PWD/escape.mjs" --allow-child-process escape.mjs on 26.10.0 the shell created escaped.txt in the working directory even though the Node.js process was granted no write access at all (observed). Without --allow-child-process the same program is refused with ERR_ACCESS_DENIED. A Node.js child, by contrast, stays restricted: a child started with process.execPath that tried to write a file was denied on all three lines (observed).

The practical rules that follow:

  • Use the model to reduce accidental authority and to document what a program needs, in addition to, never instead of, containers, dedicated OS users and network policy.

  • Never use it to run code you do not trust. For that, isolate at the OS or hypervisor level; the vm module is not a security boundary either (see Security Best Practices).

  • Prefer narrow grants. --allow-fs-read= plus a narrow --allow-fs-write is a reasonable first step; --allow-fs-write= and --allow-child-process together are close to having no model.

  • Combine with the other levers: run as an unprivileged user, drop capabilities in the container, and pass secrets through --env-file (which is read before the model starts and is outside it).

  • Report a bypass you find through the Node.js security process, as the documentation asks.

Bookshelf: a least-privilege recipe

The Bookshelf API from this section (an HTTP server on node:http with an in-memory node:sqlite database) needs very little: read its own src/ folder and package.json, and open a listening socket. A repeatable way to arrive at such a list is the audit workflow: run it under --permission-audit, read the reported resources, then enforce exactly those.

From audit to enforcement
# 1. Discover what the program touches (Node.js 26 or 24.20+)
PORT=0 node --permission-audit src/server.js

# 2. Enforce exactly that on Node.js 26; --allow-net exists only there
PORT=0 node --permission \
  --allow-fs-read="$PWD/src" --allow-fs-read="$PWD/package.json" \
  --allow-net \
  src/server.js

Observed on 26.10.0 (with the server started on port 0 so the system picks a free one): the enforced command prints listening <port>, answers a POST /books and a GET /books normally, and omitting --allow-net makes it stop at start-up with ERR_ACCESS_DENIED and permission: 'Net'. A server that writes to a database file would additionally need --allow-fs-write for that folder. Validate a real command by running the test suite under it, and keep it in package.json scripts or the config file so it is reviewed like code. On Node.js 24 and 22 leave --allow-net out; the flag does not exist there, so sockets are always allowed, which is a meaningful difference when you decide how much to trust the model on those lines.

What changed recently

Table 8. Timeline of the permission model
Version Change

v20.0.0

Introduced as --experimental-permission with --allow-fs-read, --allow-fs-write, --allow-child-process, --allow-worker.

v20.7.0

Comma-separated path lists are no longer accepted for the fs flags.

v21.6.0, v20.12.0

--allow-addons.

v22.3.0, v20.16.0

--allow-wasi.

v22.13.0, v23.5.0

Declared stable; --permission replaces --experimental-permission (the old name is still accepted on 22).

v22.16.0, v23.10.0

Node.js gains configuration-file support (--experimental-config-file), later used for the permission namespace.

v22.17.0, v24.2.0

Application entry points and --require modules are readable implicitly.

v22.18.0, v24.4.0

Permitted child processes inherit the model’s flags.

v24.12.0, v25.0.0

--allow-inspector.

v25.0.0

--allow-net (Node.js 26 line only among the LTS candidates).

v25.8.0, v24.20.0

--permission-audit and the node:permission-model:* diagnostics channels.

v26.1.0

--allow-ffi.

v26.7.0, v24.21.0

--allow-openssl-store; the configuration file becomes a Release candidate on 26.

v26.9.0

--allow-fs-vfs.

Where the books are outdated

Efficient Node.js by Samer Buna (O’Reilly, 2025) mentions the permission model only as something being introduced; it is stable now, and its practical surface is much larger than a single flag: --allow-net (Node.js 25 and later), --allow-inspector, audit mode, process.permission.drop() and the configuration file did not exist or were not settled when it was written. Node.js Projects by Jonathan Wexler (O’Reilly, 2025) does not cover the model at all, so its scripts and servers run with full authority; the patterns it uses (file uploads, child processes, third-party clients) are precisely the places where the model, plus the limits above, is worth considering. Neither book discusses the seat-belt-versus-sandbox boundary, so the safe reading of this page is the official documentation, not an older tutorial’s claim that --permission makes untrusted code safe.