Privacy Policy© 2026 DEV BAK - TECH BLOG. All rights reserved.
DEV BAK - TECH BLOG
Search posts
Backend

Node.js 22 Permission Model in Practice — How to Minimize Process Privileges and Reduce Supply Chain Attack Surface with `--allow-fs-read`

Supply chain attacks in the npm ecosystem are on the rise. The moment a single package is compromised, it becomes hard to fully trust any of the hundreds of dependencies pulled in by npm install. Lock file management, dependency auditing, and npm audit are still valid — but a second line of defense for after a compromise occurs is also necessary.

The Node.js Permission Model is exactly that. Even if a malicious package executes inside the process, it blocks filesystem access at the runtime level, minimizing the blast radius. This article explores how to apply it in real services and what pitfalls to avoid.


First, Honestly: What the Permission Model Blocks and What It Doesn't

You need to understand this limitation clearly before adopting it. The Node.js Permission Model does not control network access. This is a critical difference between Deno and Node.js.

The supported control targets are as follows:

Flag Controls
--allow-fs-read=<path> Filesystem reads
--allow-fs-write=<path> Filesystem writes
--allow-child-process Child process spawning
--allow-worker Worker Threads
--allow-addons Native addons
--allow-wasi WASI instances

Outbound HTTP/TCP is outside the Permission Model's control. Even if a compromised package attempts to send data to an external server, the Permission Model does not intervene. Network egress control must be supplemented with Kubernetes NetworkPolicy, service mesh egress policies, or container-level firewalls.

With that limitation in mind, the goal is to properly leverage the defenses the Permission Model provides in the filesystem and process permission domains.


Core Concepts

The Entry Flag and Opt-in Model

The entry point for the Permission Model is a single flag.

  • --experimental-permission — The name used in Node.js 20–22
  • --permission — The renamed version added from Node.js 23.5.0

Specifying just this one flag blocks all controllable resource access by default — filesystem, child processes, worker threads, and more. You then open only the permissions you need with individual flags.

bash
# For Node.js 22
node --experimental-permission \
  --allow-fs-read=/app/src,/app/node_modules \
  --allow-fs-write=/app/logs \
  server.js

Paths support single files, directories, and wildcards, and multiple paths can be listed with commas. The Permission Model is opt-in, so running without the flag behaves exactly as before. The biggest practical advantage is that you can introduce it incrementally by adding only the flag, with no code changes.

The flow when a file access is attempted while the model is active:

mermaid
flowchart TD
    A[File access attempted] --> B{Permission Model active?}
    B -->|Inactive| C[Access allowed]
    B -->|Active| D{Check allowed path list}
    D -->|Included| E[Access granted]
    D -->|Not included| F[ERR_ACCESS_DENIED raised]
    F --> G[Malicious code contained]

process.permission.has() — Runtime Permission Check

You can directly check the current process's permission state from within your code. This is useful not just for surfacing errors early, but for branching behavior based on permissions or providing fallbacks.

js
process.permission.has('fs.write');                    // true/false
process.permission.has('fs.read', '/etc/passwd');      // false
process.permission.has('fs.read', '/app/config');      // true (if it's an allowed path)
process.permission.has('child');                       // true/false

A practical usage pattern is graceful fallback rather than throwing an error:

js
import { createReadStream } from 'fs';
 
function readOrFallback(filePath, fallbackData) {
  if (!process.permission.has('fs.read', filePath)) {
    // Silently substitute with default value if no permission
    return fallbackData;
  }
  return createReadStream(filePath);
}

Real-World Application

Scenario 1: Running an Express API Server with Least Privilege

The most common case. Explicitly declare the paths the API server needs to read and write.

bash
node --experimental-permission \
  --allow-fs-read=/app \
  --allow-fs-write=/app/tmp,/app/logs \
  index.js

With this configuration, even if a compromised package inside node_modules tries to read /etc/passwd or .env, it will be blocked with ERR_ACCESS_DENIED. Remember that the Permission Model does not intervene for outbound HTTP requests, so network-level control must be configured separately at the infrastructure level.

Scenario 2: Data Processing Batch Scripts

ETL jobs or migration scripts that don't need network access are the easiest targets for adopting the Permission Model. There are only two access paths — input and output — so building the allow list is straightforward.

bash
node --experimental-permission \
  --allow-fs-read=/data/input \
  --allow-fs-write=/data/output \
  etl-job.js

For workloads that inherently don't need network egress, the absence of a flag like --allow-net is actually an advantage. Block the outbound connections for that process at the infrastructure firewall, and the data exfiltration path simply disappears.

Scenario 3: Defense-in-Depth in a Container Environment

In Docker/Kubernetes, combining container-level security with the Permission Model provides double coverage at two different points.

dockerfile
CMD ["node", "--experimental-permission", "--allow-fs-read=/app", "--allow-fs-write=/tmp", "server.js"]

In JSON array format (CMD [...]), the line continuation character \ cannot be used. When using multiple flags, list them on a single line or use shell form.

yaml
# kubernetes securityContext
securityContext:
  readOnlyRootFilesystem: true
  allowPrivilegeEscalation: false

Here is a summary of what threat each defense layer blocks and at what point:

Diagram 2

Install time and runtime are separate stages. Even if the package manager level protection is breached, the runtime layer still operates; and even if the Node.js process level is bypassed, the container layer remains.

Scenario 4: Sandboxing Third-Party Plugins

When running low-trust third-party code as isolated Worker Threads, you control the worker creation itself via the main process's --allow-worker flag.

js
import { Worker } from 'worker_threads';
 
// If the main process lacks --allow-worker, this line throws ERR_ACCESS_DENIED
const worker = new Worker('./plugin.js', {
  workerData: { inputPath: '/data/input' }
});
 
worker.on('error', (err) => {
  if (err.code === 'ERR_ACCESS_DENIED') {
    console.error('Plugin attempted to access a resource that is not allowed');
  }
});

The main process's Permission policy is not automatically inherited by workers. If you want to restrict a worker more narrowly, spawn the worker script as a separate node process and pass independent flags to that process.


Common Mistakes in Practice

Assuming permissions are inherited by child processes

Child processes spawned via child_process receive none of the parent's Permission policy.

js
import { spawn } from 'child_process';
 
// child.js runs independently of the parent's Permission policy
const child = spawn('node', ['child.js']);
 
// To apply it to the child as well, pass the flags directly
const safeChild = spawn('node', [
  '--experimental-permission',
  '--allow-fs-read=/child/data',
  'child.js'
]);

Placing symbolic links inside allowed paths

If you allow /app/data but there's a symbolic link like /app/data/link → /etc, a path to access locations outside the allowed range is created. Make it a habit to periodically audit symbolic links within allowed paths.

Misunderstanding the order of --env-file and the Permission Model

--env-file reads the file before the Permission Model is applied. Environment variable file loading is an initialization step that falls outside the Permission Model's protection.

Omitting access paths inside node_modules

If you don't identify all paths that packages access internally, the service won't start. The practical approach is to start broad — such as --allow-fs-read=/app — then gradually narrow it down by watching the ERR_ACCESS_DENIED logs.


Comparison with Deno

When comparing network egress control, the difference between Node.js and Deno becomes starkly clear.

Item Node.js Permission Model Deno Permission Model
Filesystem read/write control ✅ ✅
Network egress control ❌ (infrastructure-level supplement required) ✅ (--allow-net)
Child process control ✅ ✅
Environment variable access control ❌ ✅
Default security mode Opt-in Secure-by-default
Existing npm ecosystem compatibility ✅ Partial

Deno was designed as secure-by-default from the start — nothing is permitted unless explicitly allowed. Node.js chose opt-in for compatibility with the existing ecosystem, and has the practical advantage of being adoptable with just a flag and no code changes. However, to extend runtime sandboxing beyond the filesystem, infrastructure-level controls become a mandatory complement.


Closing Thoughts

The Node.js Permission Model is not a tool to prevent compromise — it's a tool to limit damage after compromise. Even if a malicious package is already executing inside the process, restricting filesystem access can block sensitive file theft and unintended writes.

Since it cannot block network egress, it only provides meaningful layered defense when used alongside Kubernetes NetworkPolicy and container security policies. Because each layer blocks a different set of threats, there is a structural advantage: even if one layer is bypassed, the next layer remains.

If you're thinking about where to start:

  • Start with batch scripts — ETL jobs and migration scripts have simple access paths, making it easiest to build the allow list, and they carry lower risk than service code.
  • Collect ERR_ACCESS_DENIED errors in the development environment — If applying directly to production is difficult, enable the flag in the development environment, collect the errors that occur, and gradually complete the allow list.
  • Pair with readOnlyRootFilesystem in container deployments — In a Kubernetes environment, apply securityContext.readOnlyRootFilesystem: true together with the Permission flag in CMD to secure both layers at once.

References

  • Permissions | Node.js v22.15.0 Official Docs
  • Permissions | Node.js v26.5.0 Official Docs
  • Node.js 22 Permission Model: Deep Dive & Benchmarks
  • Enable Node.js Permission Model | Learn Node.js Security
  • 5 Node.js Permission Model Changes Every API Developer Should Know in 2026 - DEV Community
  • Hardening Your Node.js App Against Supply Chain & RCE Attacks - DEV Community
  • Secure Node.js Applications from Supply Chain Attacks | Auth0 Blog
  • Node.js Security Best Practices for 2026 | Medium
  • How Deno protects against npm exploits | Deno Official Blog
  • Node.js June 2026 Security Releases
  • nodejs/node permissions.md — GitHub
#Node.js#Permission Model#공급망 보안#npm#최소 권한#파일시스템 보안
Share

Table of Contents

First, Honestly: What the Permission Model Blocks and What It Doesn'tCore ConceptsThe Entry Flag and Opt-in Modelprocess.permission.has()Real-World ApplicationScenario 1: Running an Express API Server with Least PrivilegeScenario 2: Data Processing Batch ScriptsScenario 3: Defense-in-Depth in a Container EnvironmentScenario 4: Sandboxing Third-Party PluginsCommon Mistakes in PracticeComparison with DenoClosing ThoughtsReferences

Recommended Posts

One schema to rule them all: generating OpenAPI 3.1, gRPC Protobuf, and TypeScript clients simultaneously — A practical guide to TypeSpec 1.0
Backend

One schema to rule them all: generating OpenAPI 3.1, gRPC Protobuf, and TypeScript clients simultaneously — A practical guide to TypeSpec 1.0

This happened to me last year. The payments team renamed the settled at field to completedAt in the /payments/{id} response. There was a JIRA ticket,…

July 20, 202623 min read
Stripping out passwords and keeping only public keys: Implementing passkey registration and authentication with Node.js and SimpleWebAuthn, and gradually integrating it with an existing login system
Backend

Stripping out passwords and keeping only public keys: Implementing passkey registration and authentication with Node.js and SimpleWebAuthn, and gradually integrating it with an existing login system

Translating the document now, following the advisor's guidance on participant IDs in sequence diagrams and all other rules. Feeling honestly nervous w…

July 19, 202627 min read
Zero-Downtime PostgreSQL Schema Migration in Practice — Deploying Column Renames to Type Changes Without Rollback Using the Expand-Contract Pattern
Backend

Zero-Downtime PostgreSQL Schema Migration in Practice — Deploying Column Renames to Type Changes Without Rollback Using the Expand-Contract Pattern

The NOT VALID + VALIDATE CONSTRAINT combination is especially powerful in PostgreSQL 12+. Because VALIDATE CONSTRAINT only acquires a SHARE UPDATE EXC…

July 19, 202611 min read
Why You Can Remove `pg`, `ioredis`, and `@aws-sdk/client-s3` — Reducing Storage Driver Dependencies with Bun's Native Clients
Backend

Why You Can Remove `pg`, `ioredis`, and `@aws-sdk/client-s3` — Reducing Storage Driver Dependencies with Bun's Native Clients

Every time you start a new project, you type npm install pg ioredis @aws sdk/client s3 , wrestle with @types/ packages, chase down version conflicts,…

July 19, 202620 min read
Building a TypeScript REST API with Fastify v5 + TypeBox
Backend

Building a TypeScript REST API with Fastify v5 + TypeBox

Let's start with the code showing the structural problem that arises when writing an API with Express. The TypeScript type, runtime validation rules,…

July 19, 202615 min read
BullMQ 5 job queue for reliably handling Node.js async tasks — covering retry strategies, Dead Letter Queue, Sandboxed Processor, and Prometheus metrics integration
Backend

BullMQ 5 job queue for reliably handling Node.js async tasks — covering retry strategies, Dead Letter Queue, Sandboxed Processor, and Prometheus metrics integration

When building a user registration API, you eventually find yourself wondering: "Should the HTTP response really be blocked while a welcome email is be…

July 19, 202623 min read