The Node.js permission model lets you run a process with almost no privileges and grant only the access it actually needs. Instead of every dependency being able to read your disk, open sockets and spawn processes, you opt in per capability from the command line. This guide covers how to enable it, how to roll it out without breaking production, and what it does not protect against.

What the Node.js permission model does

The permission model has been stable since Node.js v23.5.0 and v22.13.0 (it was added in v20). When enabled, it restricts the file system, network, child processes, worker threads, native addons, WASI, FFI and the inspector. Everything is denied unless you pass an --allow-* flag.

The official docs are blunt about the goal: it is a seat belt against trusted code doing something unintended. It is not a sandbox for hostile code. Keep that in mind throughout, and see the limitations section at the end. If you are new to the runtime itself, start with What is Node.js?.

Enable it with node --permission

Add the flag and run your entrypoint:

node --permission index.js

The entrypoint script and any -r preloads are readable automatically, but anything else is denied. A script that calls fs.readFileSync('./config.json') now fails immediately.

Grant file system access with --allow-fs-read and --allow-fs-write

File access is granted by path. Paths can be relative or absolute, comma separated, and support wildcards.

node --permission \
  --allow-fs-read=./src,./config \
  --allow-fs-write=./logs \
  src/server.js

Two details worth knowing: a directory that exists at startup gets an implicit /*, while a path that does not exist yet needs an explicit wildcard. And * grants everything of that type, which defeats the purpose, so avoid it outside of experiments.

Other capabilities have their own flags:

FlagGrants
--allow-child-processSpawning processes
--allow-workerWorker threads
--allow-netNetwork access
--allow-addonsNative addons
--allow-wasiWASI

An HTTP API typically needs read access to its own code, --allow-net, and write access to a log or temp directory, nothing else.

Handle ERR_ACCESS_DENIED errors

In enforce mode a blocked operation throws an error with code: 'ERR_ACCESS_DENIED', plus the permission and resource that were denied:

Error: Access to this API has been restricted
  code: 'ERR_ACCESS_DENIED',
  permission: 'FileSystemRead',
  resource: '/app/config/prod.json'

Read the permission and resource fields; they tell you exactly which flag to add. Note that some fs calls that act on an open file descriptor (fsync, fchmod, fchown, futimes and their variants) always throw when the model is on, because they cannot be tied to a path grant.

Roll out safely with --permission-audit

Turning on enforcement for an existing service blind is a good way to cause an outage. Audit mode runs the checks but denies nothing, publishing violations to diagnostics channels instead:

const dc = require('node:diagnostics_channel');

for (const name of ['fs', 'net', 'child', 'worker']) {
  dc.channel(`node:permission-model:${name}`).subscribe((msg) => {
    console.warn(`[perm-audit] ${msg.permission} on ${msg.resource}`);
  });
}

Run with node --permission-audit server.js, exercise the app (or let staging traffic hit it), and collect the list of accesses. That list becomes your --allow-* flags. If both --permission and --permission-audit are passed, --permission wins.

Check and drop permissions at runtime

With the model on, process.permission is available. has() checks a scope, and drop() revokes it permanently:

const fs = require('node:fs');

const config = fs.readFileSync('/etc/myapp/config.json', 'utf8');

// Done reading secrets at boot: give up read access to that dir.
process.permission.drop('fs.read', '/etc/myapp');

process.permission.has('fs.read', '/etc/myapp/config.json'); // false

Dropping is irreversible and only affects future checks. Already-open file descriptors, sockets, children and workers stay open. You can only drop the exact grant you made: a wildcard grant must be dropped as a whole scope.

Loading secrets at startup and then dropping read access is a nice pattern for services that also handle request context, like the one in AsyncLocalStorage in Node.js.

Configure permissions in a config file

Long flag lists get unwieldy. Put them under a permission key in a config file; the presence of that key enables --permission automatically:

{
  "permission": {
    "allow-fs-read": ["./src", "./config"],
    "allow-fs-write": ["./logs"],
    "allow-net": true,
    "allow-child-process": false
  }
}

Pass it with --config-file. Check the docs for your exact Node version, since the config-file feature is newer than the flags.

Run npx and worker threads under the permission model

Running npx with the model usually fails with a FileSystemRead error because Node must read the package. Grant the npm cache:

npx --node-options="--permission --allow-fs-read=$(npm config get cache)" package-name

For workers, the model does not simply carry over. A default Worker inherits the parent's CLI flags, but setting execArgv (even to an empty array) replaces them, so you must list the grants again.

Limitations: a seat belt, not a sandbox

Before you rely on this for security, read the limits in the official permissions documentation:

  • Symbolic links are followed even when they point outside granted paths.
  • Using an already-open file descriptor through node:fs bypasses the model.
  • --env-file, --openssl-config and V8 flags set at runtime sit outside the checks.
  • process._debugProcess(pid) is not gated, so a locked-down process can signal another Node process of the same OS user to open its inspector.

So treat it as defense in depth. For untrusted code, add OS-level isolation such as containers, separate users or seccomp. It pairs well with other layers like rate limiting and sound token handling.

Conclusion

The Node.js permission model is cheap to adopt: run your service under --permission-audit, convert the findings into explicit --allow-* flags, move them into a config file, and drop what you no longer need after startup. It will not stop a determined attacker, but it turns a compromised dependency's casual file read or shell spawn into an ERR_ACCESS_DENIED in your logs, which is a meaningful improvement for a one-line flag.