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:
| Stage | Filesystem | Network | Child Process |
|---|
| Install | Read/Write | Yes | Sometimes |
| Build | Read/Write | Usually limited | Sometimes |
| Unit tests | Read | Usually limited | Sometimes |
| Package | Read/Write | Usually no | Sometimes |
| Deploy | Read | Yes | Often |
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
| Capability | Build | Test | Package | Deploy |
|---|
| Read source | Yes | Yes | Yes | Usually no |
| Write build output | Yes | Sometimes | Yes | No |
| Network | Sometimes | Sometimes | Usually no | Yes |
| Child process | Sometimes | Sometimes | Sometimes | Sometimes |
| Worker threads | If required | If required | If required | If required |
| Native addons | If required | If required | Rarely | Rarely |
| WASI | Only if required | Only if required | No | No |
| FFI | Only if required | Only if required | No | No |
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
Treat Node.js permissions as least-privilege controls.
Do not treat them as a malicious-code sandbox.
Start with the smallest practical permission set.
Separate install, build, test, package, and deploy stages.
Restrict filesystem reads.
Restrict filesystem writes more aggressively.
Grant network access only when required.
Grant child-process access only when required.
Audit native addons.
Audit worker-thread requirements.
Avoid unnecessary WASI and FFI permissions.
Keep production credentials out of build jobs.
Use dedicated CI identities.
Pin Node.js versions.
Commit dependency lockfiles.
Use isolated build environments.
Test denied operations deliberately.
Document why every elevated permission exists.
Review permission profiles when dependencies change.
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.