Published on

Node.js Permission Model — Sandbox Your Backend Code

Authors

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.

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/myapp or /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:

ErrorCauseFix
Access to '...' is deniedMissing --allow-fs-readAdd path to flags
Access to '...' is deniedMissing --allow-fs-writeAdd path to write flags
Access to '...' is deniedMissing --allow-netAdd host to network flags
Access to spawn child process is deniedMissing --allow-child-processAdd 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:

FeatureNode.jsDeno
File read permissions
File write permissions
Network permissions
Child process permissions
Environment variable accessIn progress
System info accessNot yet
Prompt for permissionNo✓ Interactive
Runtime grantStartup onlyRuntime + 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-permission in 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.