Node.js  

Node.js 26 Permission Models for Secure Build Pipelines

Modern build pipelines execute a surprising amount of code.

A typical JavaScript or TypeScript build can involve:

Source Code
    ↓
npm install
    ↓
Package Lifecycle Scripts
    ↓
Build Tools
    ↓
Bundlers
    ↓
Test Framework
    ↓
Code Generation
    ↓
Deployment Scripts

Every step can potentially access files, the network, environment resources, or child processes.

This creates an important security question:

What should a build process actually be allowed to access?

Node.js provides a Permission Model that allows administrators and developers to restrict process access to resources such as the filesystem, network, child processes, worker threads, native addons, WASI, FFI, and the runtime inspector. The Permission Model is enabled with --permission and has been stable since Node.js 23.5.0 and 22.13.0.

For build pipelines, this creates an opportunity to apply least privilege directly to Node.js tooling.

But there is an important limitation:

Node.js's Permission Model is not a sandbox against malicious JavaScript.

The official documentation describes it as a "seat belt" intended to prevent trusted code from accidentally accessing resources outside its intended scope. Node.js explicitly states that malicious code can bypass the Permission Model.

That distinction should shape how it is deployed in CI/CD.

What Is the Node.js Permission Model?

The Permission Model restricts what a Node.js process is allowed to access.

Without explicit restrictions, a build script may be able to:

Read files
Write files
Open network connections
Spawn child processes
Create worker threads
Load native addons
Use WASI
Use FFI
Access the inspector

With the Permission Model enabled:

Node.js Process
      |
      +-- Filesystem
      +-- Network
      +-- Child Processes
      +-- Workers
      +-- Native Addons
      +-- WASI
      +-- FFI
      +-- Inspector

each capability can be restricted or explicitly granted.

A minimal invocation is:

node --permission build.js

The Permission Model is then enabled for that Node.js process.

Why Build Pipelines Need Least Privilege

Consider a CI job that only needs to:

Read source files
Write build artifacts

But the build process also has access to:

Entire repository
Network
Environment
Shell commands
Developer credentials
CI credentials

That is a much larger attack surface than necessary.

A least-privilege design instead aims for:

Build Process
    |
    +-- Read: source/
    +-- Write: dist/
    +-- Network: only when required
    +-- Child processes: only when required

The Permission Model can help express some of these restrictions at the Node.js runtime layer.

Important Security Limitation

The most important fact about this feature is also the easiest to misunderstand.

Node.js states that the Permission Model does not protect against malicious code. Node.js trusts the code it is asked to execute, and malicious code may bypass the restrictions.

Therefore:

Permission Model
≠
Security sandbox

Instead:

Permission Model
=
Least-privilege control for trusted code

This makes it useful for:

  • Accidental file writes

  • Unexpected dependency behavior

  • Build-tool mistakes

  • Reducing unintended resource access

  • Limiting operational blast radius

It should not be treated as the only defense against a compromised dependency.

Start With Filesystem Restrictions

The filesystem is usually the easiest capability to restrict.

Run:

node \
  --permission \
  --allow-fs-read=./src \
  --allow-fs-write=./dist \
  build.js

The exact paths should reflect the real build process.

The important principle is:

Read what the build needs
Write where the build outputs
Deny everything else

Node.js documents --allow-fs-read and --allow-fs-write as the mechanisms for granting filesystem access under the Permission Model.

Test the Restriction

Suppose the build process contains:

import { readFile } from "node:fs/promises";

const content =
    await readFile("./secret.txt", "utf8");

console.log(content);

Run the application without permission:

node --permission app.js

Node.js should reject the unauthorized filesystem operation.

The resulting error identifies the denied permission category and resource. The official documentation demonstrates this behavior with ERR_ACCESS_DENIED.

This gives CI pipelines an explicit failure instead of silently allowing an unexpected access.

Grant Only the Required Read Paths

Avoid:

--allow-fs-read=*

unless the build genuinely needs unrestricted filesystem reads.

Prefer:

--allow-fs-read=./src

or multiple narrowly scoped paths:

--allow-fs-read=./src
--allow-fs-read=./config

The goal is not to make the command difficult to maintain.

The goal is to make the required access explicit.

Restrict Write Access Even More Carefully

Build processes usually write to a smaller set of directories than they read.

For example:

Read:
src/
package.json
tsconfig.json

Write:
dist/
coverage/

A corresponding command might be:

node \
  --permission \
  --allow-fs-read=./src \
  --allow-fs-read=./package.json \
  --allow-fs-read=./tsconfig.json \
  --allow-fs-write=./dist \
  --allow-fs-write=./coverage \
  build.js

This is more restrictive than granting write access to the entire repository.

Network Access Is a Separate Capability

A build may need network access for:

Package downloads
Remote APIs
Artifact repositories
Source maps
Cloud services

But not every build step requires it.

The Permission Model can restrict network access using:

--allow-net

Node.js documents network access as one of the capabilities restricted when --permission is enabled.

For example:

node \
  --permission \
  --allow-fs-read=./src \
  --allow-fs-write=./dist \
  build.js

does not automatically grant unrestricted network access.

If network access is required, explicitly configure it according to the Node.js version and command-line options being used.

Separate Install From Build

One of the most useful CI/CD design decisions is to avoid treating the entire pipeline as one Node.js process.

Consider:

Install
   ↓
Build
   ↓
Test
   ↓
Package
   ↓
Deploy

These stages often need different permissions.

For example:

StageFilesystemNetworkChild Process
InstallRead/WriteYesSometimes
BuildRead/WriteUsually limitedSometimes
Unit testsReadUsually limitedSometimes
PackageRead/WriteUsually noSometimes
DeployReadYesOften

There is no universal configuration.

The correct permissions depend on the tools used by each stage.

Do Not Give Build Jobs Deployment Permissions

A common architectural mistake is:

Build job
   ↓
Cloud credentials
   ↓
Production deployment

If the build process is compromised, those credentials may become valuable targets.

A better separation is:

Build
   ↓
Artifact
   ↓
Deployment job
   ↓
Production credentials

The Node.js Permission Model can complement this architecture, but it does not replace CI identity isolation.

Child Processes Need Explicit Consideration

Build tools frequently spawn other programs:

git
docker
npm
esbuild
python
shell scripts

The Permission Model restricts child-process access when enabled.

Node.js documents:

--allow-child-process

for granting child-process permission.

This is particularly important in CI because many build tools depend on native executables.

For example:

import { execFile } from "node:child_process";

execFile(
    "git",
    ["rev-parse", "HEAD"],
    callback);

A restricted Node.js process may fail if child-process access has not been granted.

That failure is useful because it identifies an undocumented dependency in the build.

Do Not Automatically Grant Child Processes

If your build requires:

--allow-child-process

document why.

For example:

Build requires esbuild binary execution.

is better than:

Build needs child-process permission.

The first statement identifies the actual dependency.

Worker Threads Are Also Restricted

Modern Node.js applications and build tools may use worker threads.

The Permission Model can restrict worker-thread creation.

Node.js provides:

--allow-worker

to permit worker threads.

A build that unexpectedly starts failing after enabling the Permission Model should therefore be checked for:

Worker pools
Parallel compilation
Test runners
Bundlers
Native tooling

Do not disable the entire Permission Model just because one worker-based tool needs an explicit capability.

Native Addons Need Separate Permissions

Some Node.js packages use native addons.

The Permission Model can restrict native addon access.

Node.js provides:

--allow-addons

for this capability.

This is important for packages that rely on:

.node binaries
Native libraries
Platform-specific bindings

If a dependency requires native addons, treat that requirement as part of the build's dependency inventory.

WASI and FFI Are Additional Capabilities

The Permission Model also covers:

WASI
FFI

Node.js documents dedicated permissions for these capabilities.

Most ordinary TypeScript builds will not need them.

That is precisely why they should not be granted by default.

If a build tool requires one of these capabilities:

Identify dependency
        ↓
Document requirement
        ↓
Grant capability
        ↓
Test

The Permission Model Does Not Automatically Solve Secrets

Consider:

process.env.NPM_TOKEN

The Permission Model is not a replacement for secret-management practices.

If a process is intentionally allowed to execute code that can inspect its own environment, runtime permissions do not magically make sensitive values safe.

Use:

Short-lived credentials
Scoped tokens
OIDC where supported
Secret masking
Separate build/deploy jobs
Minimal environment variables

The Node.js Permission Model should be one layer in the security architecture.

Environment Variables Still Matter

A build process may not need every environment variable available in the CI runner.

Instead of:

Entire CI environment

prefer:

Build-specific variables

For example:

NODE_ENV
BUILD_VERSION
PUBLIC_CONFIG

while keeping:

Cloud credentials
Production secrets
Signing keys

out of the build environment whenever possible.

Use the Permission Model as a Dependency Discovery Tool

One of the practical benefits is that permission failures reveal hidden dependencies.

Start restrictive:

node --permission build.js

Run the build.

You might discover:

FileSystemRead
ChildProcess
Network

Then investigate each failure.

For example:

Build failed:
FileSystemRead denied

        ↓

Which file?

        ↓

Why does the tool need it?

        ↓

Is it required?

        ↓

Grant narrowly or remove dependency

This turns security hardening into an architecture-discovery exercise.

Runtime Permission Inspection

When the Permission Model is enabled, Node.js exposes process.permission.

You can inspect permissions with:

console.log(
    process.permission.has("fs.read")
);

The API can also check a specific resource:

console.log(
    process.permission.has(
        "fs.write",
        "./dist"
    )
);

Node.js documents process.permission.has(scope[, reference]) for runtime permission checks.

This can be useful for diagnostics and conditional behavior.

Dropping Permissions at Runtime

Node.js also exposes:

process.permission.drop(
    "fs.write"
);

The documentation states that dropping a permission is irreversible for the process and affects future access checks. It does not close resources that are already open.

This can support a useful pattern:

Startup
   ↓
Perform required setup
   ↓
Drop write permission
   ↓
Continue in reduced capability state

However, use this only when it simplifies the security model.

Do not add runtime permission manipulation simply because the API exists.

Example: Hardened Build Wrapper

A CI pipeline can use a small wrapper:

import { execFile } from "node:child_process";

if (!process.permission.has("fs.read")) {
    throw new Error(
        "Build requires filesystem read permission."
    );
}

execFile(
    "node",
    ["scripts/build.js"],
    (error, stdout, stderr) => {
        if (error) {
            console.error(stderr);
            process.exit(1);
        }

        console.log(stdout);
    });

The exact design depends on whether the wrapper itself needs child-process permission.

This example demonstrates the principle rather than prescribing a universal build architecture.

Prefer Explicit CI Commands

A CI configuration should make permissions visible.

For example:

steps:
  - name: Build
    run: >
      node
      --permission
      --allow-fs-read=./src
      --allow-fs-read=./package.json
      --allow-fs-write=./dist
      build.js

This is easier to audit than hiding all permissions inside an opaque shell script.

But Avoid Extremely Long Commands

If the permission list becomes difficult to review:

--allow-fs-read=...
--allow-fs-read=...
--allow-fs-write=...
--allow-net
--allow-worker
--allow-child-process
--allow-addons

create a dedicated build configuration or wrapper.

The objective is:

Explicit
+
Reviewable
+
Reproducible

not:

One enormous command

Node.js Configuration Files

Node.js also supports declaring permission options in a Node.js configuration file when using the experimental configuration-file mechanism.

For example:

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

Node.js documents that the permission namespace automatically enables the Permission Model when the relevant configuration-file mechanism is used.

Because configuration-file support and associated flags can have version-specific behavior, validate the exact Node.js release used by the CI image before standardizing this approach.

Use a Dedicated Build Image

A hardened build pipeline should also control the operating environment.

For example:

CI Runner
   ↓
Dedicated Node.js Build Image
   ↓
Pinned Node.js Version
   ↓
Pinned package manager
   ↓
Restricted permissions
   ↓
Build artifact

This reduces environmental variation.

The Permission Model is much more effective when combined with:

Pinned runtime
+
Pinned dependencies
+
Minimal container
+
Restricted CI identity

Pin Dependencies

Permission restrictions do not replace dependency security.

Use lockfiles:

package-lock.json
pnpm-lock.yaml
yarn.lock

depending on your package manager.

Then use deterministic installation appropriate to the chosen toolchain.

For npm:

npm ci

This ensures the CI environment follows the committed lockfile.

Separate Dependency Installation

Be careful when applying the Permission Model to:

npm install

or:

npm ci

Package installation may require:

Network
Filesystem writes
Lifecycle scripts
Native builds
Child processes

Therefore, a highly restrictive build permission profile may not work during dependency installation.

A more practical pipeline is often:

Install
   ↓
Audit
   ↓
Restricted Build
   ↓
Restricted Test
   ↓
Artifact

Each stage receives only the capabilities it actually needs.

Lifecycle Scripts Need Special Attention

npm packages can execute lifecycle scripts.

Examples include:

preinstall
install
postinstall
prepare

These scripts are part of the dependency installation process.

Therefore:

npm ci

should be treated as code execution, not merely file copying.

If your threat model treats dependency installation as untrusted, stronger isolation at the container or runner level is still required because the Node.js Permission Model is not designed as a malicious-code sandbox.

Build Pipeline Threat Model

A useful threat model distinguishes between:

Accidental Access

Build tool
    ↓
Unexpected file access

The Permission Model can be useful here.

Compromised Dependency

Malicious package
    ↓
Intentional exploitation

Do not assume the Permission Model alone can stop this.

Compromised CI Runner

Attacker
    ↓
CI host

The Node.js Permission Model cannot replace host-level isolation.

Stolen Credentials

Build
    ↓
Credential
    ↓
Cloud resource

Use identity and secret-management controls.

The correct security architecture is layered.

Recommended Defense-in-Depth Model

A secure pipeline can look like:

Source Control
      ↓
Dependency Lockfile
      ↓
Isolated CI Runner
      ↓
Minimal Identity
      ↓
Restricted Node.js Permissions
      ↓
Build
      ↓
Artifact Signing
      ↓
Deployment

Each layer addresses a different failure mode.

Measure Build Impact

Security controls should also be evaluated operationally.

Record:

Build duration
CPU
Memory
Failed permission checks
Cache hit rate
Artifact size

If enabling permissions increases build time, identify why.

Do not immediately remove the control.

Investigate:

Repeated file access
Extra child processes
Network calls
Native compilation
Tool startup

Security controls can reveal inefficient build behavior.

Test Permission Profiles Independently

Create explicit CI tests.

For example:

Build profile
    ↓
Build succeeds

Test profile
    ↓
Tests succeed

Package profile
    ↓
Artifact succeeds

Then intentionally test denied operations.

For example:

Build attempts to write outside dist/
    ↓
Expected failure

This is important because a permission configuration that is never tested can silently become obsolete.

Example Permission Matrix

CapabilityBuildTestPackageDeploy
Read sourceYesYesYesUsually no
Write build outputYesSometimesYesNo
NetworkSometimesSometimesUsually noYes
Child processSometimesSometimesSometimesSometimes
Worker threadsIf requiredIf requiredIf requiredIf required
Native addonsIf requiredIf requiredRarelyRarely
WASIOnly if requiredOnly if requiredNoNo
FFIOnly if requiredOnly if requiredNoNo

The exact values should be determined by the actual tools used in the pipeline.

Common Mistakes

Treating the Permission Model as a Sandbox

This is the biggest mistake.

Node.js explicitly states that malicious code can bypass the model.

Granting Everything

This defeats least privilege:

--allow-fs-read=*
--allow-fs-write=*
--allow-net
--allow-child-process

Use broad permissions only when they are genuinely required.

Applying One Profile to the Entire Pipeline

Install, build, test, and deployment have different requirements.

Ignoring Child Processes

Build tools often execute native binaries.

Ignoring Native Addons

Some dependencies require native components.

Exposing Production Credentials to Build Jobs

Do not give build processes credentials they do not need.

Ignoring Network Dependencies

A build may unexpectedly download assets or contact external services.

Not Testing Permission Failures

A configuration that works only because permissions are broad is not meaningfully hardened.

Assuming Permission Errors Are Application Bugs

A denied operation may reveal an undocumented dependency.

Using --allow-fs-read=* as a Shortcut

This may be convenient during debugging but undermines filesystem least privilege.

Troubleshooting

ERR_ACCESS_DENIED During Build

Inspect the permission type and resource reported by Node.js.

Then determine:

What accessed it?
Why?
Is the access required?
Can the path be narrowed?

Build Requires Child Processes

Identify the exact executable.

For example:

esbuild
git
python
docker

Then determine whether the build architecture can avoid that dependency.

If it cannot, explicitly grant the required child-process capability.

Worker Thread Error

Check whether the build tool uses worker threads.

If required, consider:

--allow-worker

rather than removing the Permission Model entirely.

Native Addon Fails

Identify which dependency loads the native addon.

Then determine whether:

--allow-addons

is required.

Node.js documents native addons as one of the capabilities restricted by the Permission Model.

Build Cannot Access the Network

Determine whether network access is actually necessary.

If it is:

Document dependency
        ↓
Grant network capability
        ↓
Prefer network-level egress restrictions

The Node.js permission flag should not be your only network-control mechanism.

Permission Model Does Not Stop Malicious Dependency Behavior

That is expected.

The official Node.js documentation explicitly states that the Permission Model is not intended to protect against malicious code.

Use:

Sandboxed CI runner
Container isolation
Network egress controls
Dependency security
Least-privilege identity

for stronger protection.

Best Practices

  1. Treat Node.js permissions as least-privilege controls.

  2. Do not treat them as a malicious-code sandbox.

  3. Start with the smallest practical permission set.

  4. Separate install, build, test, package, and deploy stages.

  5. Restrict filesystem reads.

  6. Restrict filesystem writes more aggressively.

  7. Grant network access only when required.

  8. Grant child-process access only when required.

  9. Audit native addons.

  10. Audit worker-thread requirements.

  11. Avoid unnecessary WASI and FFI permissions.

  12. Keep production credentials out of build jobs.

  13. Use dedicated CI identities.

  14. Pin Node.js versions.

  15. Commit dependency lockfiles.

  16. Use isolated build environments.

  17. Test denied operations deliberately.

  18. Document why every elevated permission exists.

  19. Review permission profiles when dependencies change.

  20. Combine runtime restrictions with container and CI isolation.

Frequently Asked Questions

Is the Node.js Permission Model a security sandbox?

No.

Node.js explicitly describes it as a "seat belt" and states that malicious code can bypass the model. It is intended primarily to prevent trusted code from unintentionally accessing resources.

What does --permission do?

It enables the Node.js Permission Model, restricting access to capabilities such as filesystem operations, network access, child processes, worker threads, native addons, WASI, FFI, and the inspector.

How do I allow filesystem reads?

Use:

--allow-fs-read=<path>

Multiple paths can be specified with multiple flags.

How do I allow filesystem writes?

Use:

--allow-fs-write=<path>

Node.js supports multiple --allow-fs-write flags for different paths.

Does the Permission Model restrict network access?

Yes.

Network access is restricted when the Permission Model is enabled and can be explicitly allowed using the appropriate network permission.

Can I use the Permission Model in CI/CD?

Yes.

Build pipelines are a practical use case because individual stages often need only a subset of system capabilities.

The restrictions should be tested against the actual build tools used by the project.

Should I enable it during npm ci?

It depends on the dependency tree.

Package installation can require network access, filesystem writes, lifecycle scripts, child processes, and native builds.

Treat dependency installation as a separate security boundary rather than assuming the same permissions as the build stage.

Does it protect secrets in environment variables?

No.

Use proper CI secret-management and identity controls.

Can permissions be checked from JavaScript?

Yes.

The runtime exposes process.permission.has() for checking permissions.

Can permissions be removed at runtime?

Yes.

process.permission.drop() can permanently drop a permission for future access checks during that process. The documentation notes that dropping a permission does not close resources that are already open.

Should every Node.js application use the Permission Model?

Not automatically.

First identify the application's capabilities and threat model.

For build pipelines and controlled tooling, it can provide useful least-privilege protection. For applications executing potentially malicious code, stronger isolation is required.

Conclusion

The Node.js Permission Model provides a useful mechanism for making build-time capabilities explicit.

Instead of assuming:

Build
  ↓
Access everything

a CI pipeline can define:

Build
  |
  +-- Read source
  +-- Write artifacts
  +-- Use network only when required
  +-- Spawn processes only when required
  +-- Use native addons only when required

That is a meaningful improvement in least-privilege design.

But the distinction between least privilege and sandboxing is critical.

Node.js explicitly warns that its Permission Model is not designed to protect against malicious code.

Therefore, a secure build architecture should combine:

Node.js Permission Model
        +
Dependency controls
        +
Isolated CI runner
        +
Minimal CI identity
        +
Network restrictions
        +
Secret management
        +
Artifact security

The most useful way to adopt the Permission Model is not to enable every restriction and hope the pipeline survives.

Instead:

Start restrictive
      ↓
Run the build
      ↓
Identify denied operations
      ↓
Understand why they occur
      ↓
Grant only required access
      ↓
Document the exception
      ↓
Test it in CI

This approach turns permission failures into architectural information.

A build that suddenly needs unrestricted filesystem access, arbitrary network access, and child-process execution may be telling you something important about its dependency chain.

The goal is therefore not simply:

"Make Node.js builds work with permissions enabled."

The goal is:

"Make every capability required by the build intentional, observable, and as narrowly scoped as practical."

That is the foundation of a more defensible Node.js CI/CD pipeline.