- Published on
Node.js Permission Model — Sandbox Your Backend Code
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Node.js 22 stabilizes the permission model, giving backends granular control over resource access. Restrict file I/O, network calls, and child processes by default, granting only what code needs. Essential for multi-tenant systems, plugin architectures, and defense in depth.
Experimental-Permission Flag (Stable in Node 22)
Enable the permission model with --experimental-permission. It's now stable in Node.js 22 LTS.
# Deny all file system, network, and child process access by default
node --experimental-permission app.js
# Allow specific directories for reading
node --experimental-permission --allow-fs-read=/var/log app.js
# Allow specific directories for writing
node --experimental-permission --allow-fs-write=/tmp app.js
# Allow network access only to specific hosts
node --experimental-permission --allow-net=api.example.com app.js
# Allow child process spawning
node --experimental-permission --allow-child-process app.js
Without explicit --allow-* flags, operations are denied by default.
File System Permissions: --allow-fs-read and --allow-fs-write
Control file system access with precise paths:
# Allow reading from home directory and system config
node --experimental-permission \
--allow-fs-read=$HOME \
--allow-fs-read=/etc/config \
app.js
# Allow writing to temp and logs
node --experimental-permission \
--allow-fs-write=/tmp \
--allow-fs-write=/var/log \
app.js
# Wildcard: allow all reads (but restrict writes)
node --experimental-permission \
--allow-fs-read=* \
--allow-fs-write=/tmp \
app.js
In code, no changes needed. Permissions are enforced at the Node.js runtime level:
import { readFileSync, writeFileSync } from 'fs';
// This throws if --allow-fs-read is not set
try {
const config = readFileSync('/etc/config/app.conf', 'utf-8');
} catch (err: any) {
console.error('Access denied:', err.message);
// Error: Access to '/etc/config/app.conf' is denied
}
// This throws if --allow-fs-write is not set
try {
writeFileSync('/tmp/log.txt', 'message');
} catch (err: any) {
console.error('Access denied:', err.message);
}
Practical example: restrict a service to read configs and write logs only:
node --experimental-permission \
--allow-fs-read=/etc/myapp \
--allow-fs-read=$HOME/.myapp \
--allow-fs-write=/var/log/myapp \
server.js
Network Permissions: --allow-net
Restrict network access to specific hosts:
# Allow outbound HTTP/HTTPS to api.example.com
node --experimental-permission \
--allow-net=api.example.com \
app.js
# Allow multiple hosts
node --experimental-permission \
--allow-net=api.example.com \
--allow-net=cdn.example.com \
--allow-net=127.0.0.1:3000 \
app.js
# Allow specific port
node --experimental-permission \
--allow-net=localhost:5432 \
app.js
# Allow wildcard (all networks, but still explicit)
node --experimental-permission \
--allow-net=* \
app.js
In code, network calls fail silently without permissions:
import http from 'http';
import https from 'https';
// Without --allow-net=api.example.com, this throws
https.get('https://api.example.com/data', (res) => {
console.log(res.statusCode);
}).on('error', (err) => {
console.error('Network error:', err.message);
// Error: Access to 'api.example.com' is denied
});
// Database connections also respect --allow-net
import { Client } from 'pg';
const client = new Client({
host: 'db.example.com',
port: 5432,
user: 'app',
password: process.env.DB_PASSWORD,
database: 'app_db',
});
try {
await client.connect(); // Throws if --allow-net=db.example.com not set
} catch (err) {
console.error('Database connection denied');
}
Child Process Permissions: --allow-child-process
Control spawning of child processes:
# Deny all child process spawning by default
node --experimental-permission app.js
# Allow child processes (all commands)
node --experimental-permission \
--allow-child-process \
app.js
# Specific commands (granular control, future feature)
node --experimental-permission \
--allow-child-process=/usr/bin/bash \
app.js
Without permission, spawn(), exec(), and fork() throw:
import { spawn, exec } from 'child_process';
// Throws without --allow-child-process
spawn('ls', ['-la']).on('error', (err) => {
console.error('Child process denied:', err.message);
// Error: Access to spawn child process is denied
});
exec('curl https://example.com', (err, stdout) => {
if (err) {
console.error('Exec denied:', err.message);
}
});
Worker Thread Permissions
Worker threads inherit parent permissions:
import { Worker } from 'worker_threads';
import { fileURLToPath } from 'url';
// Parent process: node --experimental-permission --allow-fs-read=/data app.js
const worker = new Worker(new URL('./worker.js', import.meta.url));
worker.on('message', (msg) => {
console.log('Worker result:', msg);
});
worker.on('error', (err) => {
console.error('Worker error:', err);
});
// worker.js
import { readFileSync } from 'fs';
try {
const data = readFileSync('/data/file.json', 'utf-8'); // ✓ Allowed
parentPort.postMessage(JSON.parse(data));
} catch (err) {
console.error('Read denied:', err.message);
}
Worker threads run under the same permission constraints as the parent.
- Experimental-Permission Flag (Stable in Node 22)
- File System Permissions: --allow-fs-read and --allow-fs-write
- Network Permissions: --allow-net
- Child Process Permissions: --allow-child-process
- Worker Thread Permissions
- Using Permissions in Production (Principle of Least Privilege)
- Permission Denied Errors and Debugging
- Combining Permissions with Docker
- Limitations vs Deno's Permission Model
- Checklist
- Conclusion
Using Permissions in Production (Principle of Least Privilege)
Best practice: grant minimal required permissions.
// server.js for a typical Node.js API
import express from 'express';
import { readFileSync } from 'fs';
import https from 'https';
const app = express();
// Permissions for this app:
// - Read from /etc/myapp (config) and /var/cache/myapp (temp files)
// - Write to /var/log/myapp (logs)
// - Network access to db.prod.example.com and api.payment.example.com
// - No child processes
// Config: read at startup
const config = JSON.parse(
readFileSync('/etc/myapp/config.json', 'utf-8')
);
// Logging: restricted to /var/log/myapp
import winston from 'winston';
const logger = winston.createLogger({
transports: [
new winston.transports.File({
filename: '/var/log/myapp/error.log',
level: 'error',
}),
new winston.transports.File({
filename: '/var/log/myapp/app.log',
}),
],
});
// API endpoint
app.get('/users/:id', async (req, res) => {
try {
// Database call to allowed host
const user = await db.query(
'SELECT * FROM users WHERE id = $1',
[req.params.id]
);
res.json(user);
} catch (err) {
logger.error('Query failed', err);
res.status(500).json({ error: 'Internal server error' });
}
});
app.listen(3000, () => {
logger.info('Server started on port 3000');
});
Production startup:
node --experimental-permission \
--allow-fs-read=/etc/myapp \
--allow-fs-read=/var/cache/myapp \
--allow-fs-write=/var/log/myapp \
--allow-net=db.prod.example.com \
--allow-net=api.payment.example.com \
--allow-net=127.0.0.1:3000 \
server.js
If a vulnerability allows code injection, the attacker cannot:
- Read sensitive files outside
/etc/myappor/var/cache/myapp - Write anywhere except
/var/log/myapp - Exfiltrate data to unauthorized hosts
- Spawn malicious processes
Permission Denied Errors and Debugging
When permission is denied, code receives clear errors:
import { readFileSync } from 'fs';
try {
readFileSync('/etc/shadow');
} catch (err: any) {
if (err.code === 'ERR_ACCESS_DENIED') {
console.error('Permission denied:', err.message);
// Permission denied, access '/etc/shadow'
console.error('Path:', err.path);
console.error('Code:', err.code);
}
}
Debug what's being denied:
# Enable permission debugging
node --experimental-permission \
--allow-fs-read=/tmp \
--expose-internals \
app.js 2>&1 | grep -i "access"
Common errors:
| Error | Cause | Fix |
|---|---|---|
Access to '...' is denied | Missing --allow-fs-read | Add path to flags |
Access to '...' is denied | Missing --allow-fs-write | Add path to write flags |
Access to '...' is denied | Missing --allow-net | Add host to network flags |
Access to spawn child process is denied | Missing --allow-child-process | Add flag |
Combining Permissions with Docker
Run Node.js in Docker with permission flags:
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
USER node
ENTRYPOINT [ "node", "--experimental-permission" ]
CMD [
"--allow-fs-read=/app/dist",
"--allow-fs-write=/var/log/app",
"--allow-net=*",
"dist/server.js"
]
With Kubernetes, pass flags via command:
spec:
containers:
- name: api
image: myapp:latest
command:
- node
- --experimental-permission
- --allow-fs-read=/app/dist
- --allow-fs-write=/var/log
- --allow-net=db.example.com
- dist/server.js
ports:
- containerPort: 3000
Combine permissions with OS-level security:
- Linux capabilities: drop
CAP_NET_ADMIN,CAP_SYS_ADMIN - AppArmor/SELinux profiles
- Read-only root filesystem
- Non-root user
Limitations vs Deno's Permission Model
Node.js permissions are improving but still lag Deno's mature model:
| Feature | Node.js | Deno |
|---|---|---|
| File read permissions | ✓ | ✓ |
| File write permissions | ✓ | ✓ |
| Network permissions | ✓ | ✓ |
| Child process permissions | ✓ | ✓ |
| Environment variable access | In progress | ✓ |
| System info access | Not yet | ✓ |
| Prompt for permission | No | ✓ Interactive |
| Runtime grant | Startup only | Runtime + interactive |
Node.js permissions are now practical for production. As they mature, expect parity with Deno.
Checklist
- Update to Node.js 22 LTS
- Identify all file system, network, and process access your app needs
- Document required permissions in README
- Test with
--experimental-permissionin development - Add permission flags to production startup scripts
- Verify third-party dependencies respect permission boundaries
- Monitor logs for permission denied errors
- Combine Node.js permissions with Docker/Kubernetes security
- Audit and minimize required permissions annually
- Consider Deno for greenfield security-critical projects
Conclusion
Node.js 22's permission model provides production-ready defense against code injection and compromise. By restricting file system, network, and process access at the runtime level, you implement defense in depth without application changes. Essential for multi-tenant systems, plugin architectures, and any backend that runs untrusted code.