Security Permissions Crypto Secrets Sandboxing and Dependency Risk

Reading time
15 min read
Word count
2918 words
Diagram count
0 diagrams

Source: Victor Bona's Obsidian Compendium snapshot, Knowledge base/nodejs-v8-runtime-engineering/16 Security Permissions Crypto Secrets Sandboxing and Dependency Risk.md.

Purpose: Build a production security field manual for Node.js V8 Runtime Engineering that connects Node permissions, process boundaries, cryptography, Web Crypto, secrets, sandboxing, dependency supply chain, and operational incident response.

16 Security Permissions Crypto Secrets Sandboxing and Dependency Risk

Related: Node.js V8 Runtime Engineering, 16 Security Permissions Crypto Secrets Sandboxing and Dependency Risk, 17 Production Operations Deployment Containers Scaling and Runbooks, 18 Node.js Ecosystem Frameworks Tooling and Learning Projects, 07 npm pnpm yarn Packages Lockfiles Supply Chain and Monorepos, 10 Filesystem Processes Signals Workers Cluster and Child Processes, 11 Networking HTTP TLS DNS Sockets Undici and Fetch, 12 Web Platform APIs in Node.js URL Blob Web Streams AbortController and Test Runner

Security stance

Node.js security is boundary engineering. A service runs untrusted network input through application code, third-party packages, native libraries, OpenSSL, V8, libuv, filesystem APIs, child processes, environment variables, container images, host kernels, and cluster policy. No single Node flag turns this into a sandbox.

Treat these as distinct controls:

ControlProtects againstDoes not protect against
Input validationmalformed user data reaching business logicdependency compromise, stolen secrets, host escape
Authn and authzunauthenticated or overprivileged usersSSRF, deserialization bugs, leaked service credentials
Node Permission Modeltrusted code accidentally using denied resourcesmalicious code that can exploit the runtime or same-user OS capabilities
node:crypto and Web Cryptoconfidentiality, integrity, signatures, key derivationbad key management, weak algorithms, replay, side channels in app logic
Secret managerstatic secrets in repo or imageruntime exfiltration from a compromised process
Container isolationprocess, filesystem, capability, cgroup boundarieskernel bugs, overbroad service accounts, hostPath mounts
Kubernetes policynamespace, network, identity, admission, rollout boundariesapplication-level injection, bad npm package scripts
Supply-chain policyknown vulnerable or unexpected package graphzero-day package compromise, maintainer takeover

Security reviews must name the boundary being defended. "Use permissions" is not a threat model. "This image runs as non-root, cannot write outside /tmp, cannot open arbitrary outbound sockets, uses read-only root filesystem, and has no package manager in the runtime layer" is a boundary.

Current source anchors

AreaCurrent anchor
PermissionsNode.js v26 API docs for permissions and CLI --permission grants
CryptoNode.js v26 node:crypto, Web Crypto, TLS, and OpenSSL-backed APIs
Process behaviorNode.js v26 process events, process.exitCode, POSIX identity APIs, warnings, reports
TestsNode.js v26 node:test for test isolation, coverage, randomization, and global setup
Supply chainnpm docs for npm ci, npm audit, provenance, trusted publishing, scripts, SBOM, and threats
Release policyNode.js official releases page for production LTS guidance

Threat model layers

LayerExample attackPrimary evidencePrimary defense
HTTP boundaryrequest smuggling, oversized body, header abuseaccess logs, reverse proxy logs, packet captureproxy limits, strict parsers, timeouts
Auth boundaryconfused deputy, missing tenant checkaudit logs, trace attributes, policy decision logscentralized authorization, deny by default
Serialization boundaryprototype pollution, JSON bombspayload samples, heap growth, errorsschema validation, payload limits, safe merge
Filesystem boundarypath traversal, temp file racedenied paths, fs audit logs, exception tracescanonical paths, mkdtemp, permissions
Process boundarycommand injection, inherited env leakchild argv, shell history, env snapshotno shell, explicit argv, minimal env
Network boundarySSRF, metadata service accessoutbound DNS and connect logsegress allowlist, metadata firewall, --allow-net where useful
Package boundarymalicious install scriptlockfile diff, registry provenance, lifecycle logsfrozen install, script policy, review
Native boundarycompromised addon, ABI mismatchloaded modules, crash dump, process.versionsminimize addons, rebuild in CI, image pinning
Cluster boundaryoverbroad service accountKubernetes audit logRBAC, NetworkPolicy, admission controls

Node Permission Model

Node's Permission Model is enabled with --permission. In current Node v26 docs it is stable, and the docs describe it as a process-based permission system for restricting access to resources such as filesystem, network, child processes, worker threads, native addons, WASI, FFI, and inspector. The same docs warn that it does not provide a malicious-code sandbox. Use it as defense in depth for trusted application code.

Permission mental model

QuestionAnswer
Is it default-on?No. Start Node with --permission.
What happens without grants?Access to restricted resources fails with ERR_ACCESS_DENIED.
Can it restrict reads separately from writes?Yes, filesystem read and write grants are separate.
Can code check permissions?Yes, process.permission.has(scope, reference) is available when enabled.
Can code reduce permissions?Yes, process.permission.drop(scope, reference) can remove future access.
Does dropping close existing handles?No. Already open files, sockets, workers, and child processes remain the app's responsibility.
Is it a sandbox for untrusted JS?No. Treat hostile code as requiring process, user, container, VM, or isolate boundaries outside ordinary app execution.

Common grants

GrantUseProduction caution
--allow-fs-read=/app,/etc/ssl/certsapp files and CA materialavoid * unless the process is already strongly isolated
--allow-fs-write=/tmp,/var/run/apptemp files, sockets, reportskeep logs on stdout where possible
--allow-net=api.internal:443,redis.internal:6379outbound clientspair with cluster egress policy
--allow-child-processmigrations or controlled subprocessesforked Node processes can inherit relevant execution args
--allow-workerCPU worker poolnot needed for normal network IO
--allow-addonsnative addonsnative code is a high-trust boundary
--allow-wasiWASI workloadsWASI preopens can expose files
--allow-ffi plus --experimental-ffiFFI experimentsactive-development surface, avoid in high-trust services
--allow-inspectordebugger and inspector sessionsdo not expose inspector in production networks

Example startup:

node \
  --permission \
  --allow-fs-read=/app,/etc/ssl/certs \
  --allow-fs-write=/tmp \
  --allow-net=api.internal:443,redis.internal:6379 \
  ./dist/server.mjs

Runtime check:

import process from 'node:process';

export function assertStartupPermissions() {
  const required = [
    ['fs.read', '/app'],
    ['fs.write', '/tmp'],
    ['net', 'api.internal:443'],
  ];

  for (const [scope, reference] of required) {
    if (!process.permission?.has(scope, reference)) {
      throw new Error(`missing permission ${scope} ${reference}`);
    }
  }
}

Reducing access after startup:

import process from 'node:process';
import { readFile } from 'node:fs/promises';

const privateKey = await readFile('/run/secrets/jwt-key.pem', 'utf8');

process.permission?.drop('fs.read', '/run/secrets/jwt-key.pem');

export { privateKey };

This reduces future reads. It does not erase the key from memory. Store key material in KeyObject where possible, avoid logging it, rotate it, and design compromise response.

Permission footguns

FootgunWhy it hurtsSafer posture
--allow-fs-read=* in productionturns filesystem read control into documentationgrant explicit paths
--allow-net=* in services that parse URLsSSRF blast radius stays broadgrant exact upstream hosts and ports
treating --permission as a sandboxhostile code can use same-user or runtime escape pathsisolate with OS users, containers, VM, or separate service
starting with npm start in containernpm may affect signal handling and adds another executable surfaceCMD ["node", "dist/server.mjs"]
granting child processes for one scriptevery command injection bug becomes more powerfulavoid shell, use explicit argv, isolate scripts
granting worker threads casuallyworkers share process trust and may share memoryuse for CPU work only, not untrusted code
relying on drop() for revocationopen handles remain usableclose handles before dropping
using environment variables as sole secret controlany compromised process can read its own envuse workload identity and short-lived secret fetch where possible

Sandboxing untrusted code

Untrusted code should not run in the same Node process as production secrets. vm, dynamic import(), workers, ESM loaders, and permission flags are not enough by themselves for hostile code.

RequirementMinimum viable boundary
run student code on a laptopdisposable local container with no host mounts except scratch
run customer plugins in SaaSseparate process under separate OS user, no secrets, egress denied by default
run arbitrary code at scalesandbox service using gVisor, Firecracker, Kata, or equivalent VM-grade isolation
run WASM extensionscapability-based WASI preopens, no inherited secrets, CPU and memory quotas
run build scriptsephemeral CI worker, network policy, read-only source checkout after install

Bad pattern:

import vm from 'node:vm';

vm.runInNewContext(userCode, { console });

Better architecture:

API service
  validates request
  writes job to queue
  sends no production secret

Sandbox worker
  runs as different OS user
  read-only image
  no service account token
  no default egress
  CPU, memory, pid, and wall-clock limits
  writes result to bounded scratch path

Sandbox checklist:

ControlLocal learning machineProduction Linux hostProduction containerProduction cluster
identitylocal userdedicated Unix user per trust classnon-root UIDdistinct service account
filesystemtemp directorychroot or dedicated workdirread-only root, scratch volumeno hostPath, limited emptyDir
networkdisabled or loopbackfirewall or netnsno default outboundNetworkPolicy and egress proxy
processmanual cleanupcgroup or systemd limitspid limitpod security and runtime class
secretsnoneno inherited envno mounted secretsprojected token disabled where not needed
cleanupdelete temp dirsystemd cleanupcontainer exit removes stateTTL jobs and volume cleanup

Filesystem security

Filesystem bugs in Node services usually come from path joins, temp file handling, upload extraction, and permission confusion.

Path traversal guard

import path from 'node:path';

const root = '/srv/app/uploads';

export function resolveUploadPath(userPath) {
  const resolved = path.resolve(root, userPath);
  const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`;

  if (resolved !== root && !resolved.startsWith(rootWithSep)) {
    throw new Error('path escapes upload root');
  }

  return resolved;
}

Rules:

RuleReason
resolve against an absolute rootavoids current-working-directory surprises
compare canonical prefixes carefully/srv/app/uploads2 must not match /srv/app/uploads
never trust uploaded archive pathszip slip is a path traversal variant
create temp directories with secure APIsavoid predictable names and symlink races
handle ENOENT, EACCES, and EPERM at use timepre-checks can race before use

Process and shell security

Avoid shell execution for user-controlled values.

Bad:

import { exec } from 'node:child_process';

exec(`convert ${userFile} out.png`);

Better:

import { spawn } from 'node:child_process';

const child = spawn('convert', [safeInputPath, 'out.png'], {
  shell: false,
  env: {
    PATH: '/usr/bin:/bin',
  },
  stdio: ['ignore', 'pipe', 'pipe'],
});

Hardening:

ControlImplementation
no shellspawn(command, args, { shell: false })
fixed executableabsolute path or controlled PATH
bounded outputstream and cap stdout and stderr
timeoutkill child after deadline
no inherited secretspass minimal env
no broad permissionsavoid --allow-child-process unless required

Crypto mental model

Use node:crypto for Node-native OpenSSL-backed cryptography and crypto.webcrypto.subtle for Web Crypto compatibility. Prefer boring, reviewed constructions over hand-rolled protocols.

NeedPreferAvoid
random identifiersrandomUUID() or randomBytes()Math.random()
password hashingArgon2id via vetted package or scrypt when availableraw SHA-256, fast HMAC, unsalted hashes
API token hashingHMAC or keyed hash plus constant-time compareplaintext tokens in database
symmetric encryptionAEAD such as AES-GCM or ChaCha20-Poly1305 where supportedCBC without authentication
signaturesEd25519, ECDSA P-256, RSA-PSS where policy allowsRSA PKCS#1 v1.5 for new designs
key exchangeTLS or vetted protocol librarycustom Diffie-Hellman protocol
secret comparisontimingSafeEqual with same-length buffers=== on secrets

Password hashing example

import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import { promisify } from 'node:util';

const scryptAsync = promisify(scrypt);

export async function hashPassword(password) {
  const salt = randomBytes(16);
  const key = await scryptAsync(password, salt, 64, {
    N: 1 << 15,
    r: 8,
    p: 1,
    maxmem: 64 * 1024 * 1024,
  });

  return `scrypt$32768$8$1$${salt.toString('base64')}$${key.toString('base64')}`;
}

export async function verifyPassword(password, encoded) {
  const [scheme, n, r, p, saltB64, keyB64] = encoded.split('$');
  if (scheme !== 'scrypt') return false;

  const salt = Buffer.from(saltB64, 'base64');
  const expected = Buffer.from(keyB64, 'base64');
  const actual = await scryptAsync(password, salt, expected.length, {
    N: Number(n),
    r: Number(r),
    p: Number(p),
    maxmem: 64 * 1024 * 1024,
  });

  return expected.length === actual.length && timingSafeEqual(expected, actual);
}

Production notes:

ConcernGuidance
work factorbenchmark on production-like hardware and keep login p99 acceptable
denial of servicerate limit login attempts and cap concurrent hashing
migrationstore algorithm and parameters with the hash
rotationrehash on successful login when policy changes
memoryscrypt and Argon2 consume memory by design

AEAD encryption example

import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';

const algorithm = 'aes-256-gcm';

export function encryptJson(key, value, aad) {
  const iv = randomBytes(12);
  const cipher = createCipheriv(algorithm, key, iv);
  cipher.setAAD(Buffer.from(aad, 'utf8'));

  const plaintext = Buffer.from(JSON.stringify(value), 'utf8');
  const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
  const tag = cipher.getAuthTag();

  return {
    iv: iv.toString('base64'),
    ciphertext: ciphertext.toString('base64'),
    tag: tag.toString('base64'),
  };
}

export function decryptJson(key, envelope, aad) {
  const decipher = createDecipheriv(algorithm, key, Buffer.from(envelope.iv, 'base64'));
  decipher.setAAD(Buffer.from(aad, 'utf8'));
  decipher.setAuthTag(Buffer.from(envelope.tag, 'base64'));

  const plaintext = Buffer.concat([
    decipher.update(Buffer.from(envelope.ciphertext, 'base64')),
    decipher.final(),
  ]);

  return JSON.parse(plaintext.toString('utf8'));
}

Footguns:

FootgunConsequence
IV reuse with same key in GCMcatastrophic confidentiality and integrity failure
encryption without authenticationattackers can tamper with ciphertext
decrypting before authenticatingoracle-style bugs and confusing errors
storing keys beside ciphertextcompromise gets both lock and key
logging decrypted payloadssecrets bypass encryption entirely
swallowing auth tag failurescorruption or attack is treated as normal data

Web Crypto in Node

Web Crypto is useful when code must share cryptographic primitives with browser or edge runtimes. It is promise-based and uses CryptoKey objects rather than Node KeyObject APIs.

Use Web Crypto whenUse node:crypto when
sharing code with browsersintegrating with OpenSSL-backed Node APIs
implementing standards that specify Web Cryptostreaming hashes or ciphers are needed
using SubtleCrypto key import and export formatsusing Node certificates, TLS, or legacy APIs
running in browser-shaped runtimesperformance and operational compatibility are Node-only

Example HMAC:

const enc = new TextEncoder();

export async function hmacSha256(secret, message) {
  const key = await crypto.subtle.importKey(
    'raw',
    enc.encode(secret),
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  );

  const signature = await crypto.subtle.sign('HMAC', key, enc.encode(message));
  return Buffer.from(signature).toString('base64url');
}

TLS and certificates

Node TLS is built on OpenSSL. Application code should usually let a reverse proxy or service mesh terminate public TLS, but services still need correct outbound TLS.

ScenarioGuidance
public HTTP APIterminate TLS at ingress or load balancer, preserve client identity headers carefully
service-to-serviceuse mTLS through mesh or explicit TLS with pinned CA where required
outbound vendor APIrely on OS or image CA bundle, monitor expiry errors
private CAmount CA bundle read-only and grant read permission if using Permission Model
local developmentuse local CA tooling, do not disable verification in shared code

Do not ship NODE_TLS_REJECT_UNAUTHORIZED=0. It converts certificate failure into silent trust.

Secrets

Secrets are runtime capabilities. They should not exist in source control, package artifacts, images, logs, stack traces, telemetry attributes, heap snapshots, or diagnostic reports unless explicitly redacted.

Secret typePreferred deliveryRotation posture
database passwordsecret manager or workload identityrotate with connection drain
JWT signing keyKMS or mounted secret filekey id and overlapping verification window
npm publish credentialtrusted publishing OIDCavoid long-lived token
third-party API tokensecret manager env or fileshort TTL if provider supports it
TLS private keycertificate manager or ingress secretautomated renewal
encryption keyKMS envelope encryptionrotate data key plan

Environment variables are convenient but leaky:

Leak pathMitigation
crash reportsredact reports, restrict report generation
process listingsavoid passing secrets as argv
logscentral redaction and structured logging discipline
child processespass minimal env
support bundlesscrub before upload
test snapshotsblock secret-shaped fixtures

Dependency risk

npm packages are code execution surfaces. Installing, building, importing, and running can all execute third-party code.

StageRiskControl
dependency selectiontyposquatting, dependency confusion, abandoned packagepackage review, internal registry scopes
installlifecycle scripts executedisable scripts where possible, isolate CI
lock updatecompromised transitive versionsmall PRs, lockfile diff review, audit
buildnative addon compile or postinstall fetchno network during build after fetch phase
runtime importmalicious behavior in app processleast privilege, permissions, egress policy
publishstolen npm tokentrusted publishing and provenance

Frozen install

npm ci
npm audit --audit-level=high
npm sbom --sbom-format=cyclonedx --json > sbom.cdx.json

For production images:

npm ci
npm run build
npm prune --omit=dev

or use a multi-stage build where the final image receives only built artifacts, production dependencies, package.json, lockfile, and required assets.

Lockfile review

Diff signalReview question
new package with similar nametypo or dependency confusion?
new install scriptwhy does install need code execution?
tarball URL changedregistry or source changed?
many transitive bumpsdid a broad range update hide risk?
native addon addedare build tools and ABI stable?
license changedlegal or distribution impact?
maintainer or repository changedtakeover or ownership transition?

Trusted publishing and provenance

For packages you publish, prefer npm trusted publishing from supported CI providers. Current npm docs describe trusted publishing as OIDC-based and note that it avoids long-lived npm tokens. npm provenance lets consumers see where and how a package was built.

Operational rules:

RuleReason
prefer trusted publisher over npm tokenreduces secret lifetime and storage
protect release workflow pathOIDC trust only helps if workflow changes are reviewed
require branch protectionprevents unreviewed release code
pin release action versionsreduces workflow dependency drift
publish from clean CIavoids developer machine state
verify package contents before publishnpm pack --dry-run catches accidental files

Application security patterns

Input validation

Validate at the boundary and preserve typed data internally.

const userIdPattern = /^[a-zA-Z0-9_-]{3,64}$/;

export function parseUserId(value) {
  if (typeof value !== 'string' || !userIdPattern.test(value)) {
    throw Object.assign(new Error('invalid user id'), { statusCode: 400 });
  }
  return value;
}

Boundary checklist:

InputChecks
JSON bodymax size, content type, schema, unknown fields
query stringarrays, repeated keys, type coercion
headerssize, canonical casing, trust proxy rules
uploadfile size, type sniffing, storage path, antivirus where required
URLprotocol allowlist, host allowlist, DNS rebinding risk
webhookraw body signature, timestamp skew, replay cache

SSRF controls

const allowedHosts = new Set(['api.stripe.com', 'api.github.com']);

export function parseOutboundUrl(raw) {
  const url = new URL(raw);
  if (url.protocol !== 'https:') throw new Error('https required');
  if (!allowedHosts.has(url.hostname)) throw new Error('host denied');
  if (url.username || url.password) throw new Error('embedded credentials denied');
  return url;
}

Production SSRF defense is layered:

LayerControl
appparse URL once and allowlist hostname
DNSblock metadata and private ranges where possible
networkegress proxy or NetworkPolicy
runtime--allow-net exact host grants where practical
clouddisable metadata v1, require hop limit and tokens

Security testing

Use the built-in test runner for security invariants when it is enough. Current Node docs include node --test, process-level test isolation by default, coverage, global setup, watch mode, and randomization. Randomized test order is useful for detecting state leakage.

import test from 'node:test';
import assert from 'node:assert/strict';
import { resolveUploadPath } from '../src/path-policy.mjs';

test('upload paths cannot escape root', () => {
  assert.throws(() => resolveUploadPath('../secrets.env'), /escapes/);
});

test('absolute paths cannot escape root', () => {
  assert.throws(() => resolveUploadPath('/etc/passwd'), /escapes/);
});

CI examples:

node --test --test-randomize
node --test --experimental-test-coverage
npm audit --audit-level=high
npm sbom --sbom-format=cyclonedx --json > sbom.cdx.json

Incident response

Suspected leaked secret

StepAction
containrevoke or rotate credential immediately
scopesearch logs, traces, crash reports, support bundles
preservesave relevant audit logs and deployment versions
eradicateremove source of leak and add regression test
recoverredeploy, verify new credential use, monitor failures
learndocument trigger, detection gap, and rotation time

Suspected dependency compromise

StepAction
freezestop automated dependency updates and publishes
identifycompare lockfile, installed tree, and package tarball
isolaterebuild in clean CI without caches
inspectreview install scripts and package contents
rotaterotate secrets exposed to affected build or runtime
replacepin safe version, fork, or remove package
reportfollow vendor and registry security process

Unexpected ERR_ACCESS_DENIED

SymptomLikely causeFix
app cannot read its entrypointmissing --allow-fs-read for app pathgrant /app or exact dist path
TLS calls fail reading CACA bundle path deniedgrant CA bundle read path
image works locally but not in clusterdifferent working directory or mounted pathlog process.cwd() and resolve absolute grants
tests fail under permissionschild test processes need inherited grantsrun exact test command under intended flags
worker creation failsmissing --allow-workergrant only if workers are required
network fails by hostgrant mismatch on host or portnormalize target host and port

Production checklist

AreaMinimum production posture
runtime versionActive LTS or Maintenance LTS for production unless knowingly running Current
app usernon-root, dedicated identity
filesystemread-only root where possible, explicit writable dirs
networkinbound through proxy, outbound allowlist
secretsno secrets in image, repo, logs, or argv
cryptono custom algorithms, authenticated encryption, strong randomness
dependenciesfrozen install, lockfile review, audit, SBOM
install scriptsminimized and isolated
permissions--permission for defense in depth where compatible
diagnosticsredaction for reports, profiles, logs, and traces
incident responserotation runbook and dependency rollback runbook

Troubleshooting table

SymptomFirst checksCommon fix
ERR_OSSL_EVP_UNSUPPORTEDalgorithm, OpenSSL version, Node versionmigrate weak algorithm or configure provider policy only with security review
JWT verification flapsclock skew, key id, rotated key cacheoverlap keys and respect kid
password login p99 spikescrypt cost, CPU throttling, concurrencytune work factor and rate limit
heap snapshot contains secretssecret stored as string, report workflow broaduse key handles where possible and restrict diagnostics
npm audit floodseverity, reachability, dev versus prod pathtriage known reachable risks first
install script phones homepackage postinstallblock, replace, or isolate package
production cannot write temp filesread-only root or denied permissionwrite only to mounted scratch path
webhook signature invalidraw body parsing changedverify against raw bytes before JSON parsing