Skip to content

The Security Model

By default, Node code runs with the full authority of the OS user that started it — no sandbox, no capability system, unless you opt into one. This lesson covers the boundaries Node actually gives you, and — just as important — the places that look like a boundary and are not one.

--permission: an explicit allowlist, opt-in

Section titled “--permission: an explicit allowlist, opt-in”

With --permission, Node denies filesystem, network, child-process, and worker access by default, and you grant back exactly what the process needs:

Terminal window
node read-secret.js
top secret
Terminal window
node --permission read-secret.js
Error: Access to this API has been restricted. Use --allow-fs-read to manage permissions.
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: './secret.txt'
Terminal window
node --permission --allow-fs-read=./secret.txt read-secret.js
top secret

This is a real security boundary (unlike the two “boundaries” below): the permission model is enforced by Node itself before the operation runs, not by convention. It is still labeled experimental — treat it as defense-in-depth for a process running less-trusted code, not yet as the only thing standing between your process and the filesystem.

--env-file: loading secrets without a dependency

Section titled “--env-file: loading secrets without a dependency”

Node reads a .env-style file directly, no dotenv package required:

Node.js
Output (verified on Node 24)
API_KEY without --env-file: undefined
Terminal window
node --env-file=.env read-env.js
API_KEY is super-secret-value

The security-relevant habit this replaces: printing process.env (or an object that includes it) in an error handler or a debug log is a common way secrets leak into log aggregators that plenty of people can read — treat process.env itself as sensitive, not just the .env file it came from.

Three specific, common mistakes and their fixes, all real behavior on Node 24.13:

Node.js
Output (verified on Node 24)
tokens must come from a CSPRNG, not Math.random
random token length (hex chars): 32
same password + same salt -> same hash: true
timingSafeEqual result: true

scrypt/pbkdf2 (not raw sha256/sha512) for passwords specifically because a fast general-purpose hash lets an attacker with a leaked hash database try billions of guesses per second on commodity hardware — scrypt’s deliberate memory cost makes that dramatically more expensive. timingSafeEqual matters anywhere you compare a secret to user-supplied input (an API key, an HMAC signature): a naive === short-circuits on the first mismatched byte, and the tiny timing difference between “failed on byte 1” and “failed on byte 30” is a real, exploitable side channel over enough requests.

Prototype pollution: a naive deep-merge is an attack surface

Section titled “Prototype pollution: a naive deep-merge is an attack surface”

A recursive merge that does not check for __proto__/constructor/prototype keys lets attacker-controlled JSON reach Object.prototype itself:

Node.js
Output (verified on Node 24)
innocentObject.isAdmin (should not exist): true

The merge never touched innocentObject directly — it polluted Object.prototype, so every plain object in the process now inherits isAdmin: true, including ones created long before or after the malicious merge. This is not a contrived example: it is a real, repeatedly-disclosed vulnerability class in npm packages that implement their own deep-merge/clone/extend. Fixes: reject __proto__/constructor/prototype keys explicitly before recursing, use Object.create(null) for objects meant to hold arbitrary user keys, or use a merge utility from a library that already patches this (and keep it updated — this exact bug class has been reintroduced by “fixed” libraries more than once). The TypeScript course’s practical-mastery module walks the same vulnerability from the type-safety angle, if you want the other half of the picture.

eval, new Function, and vm are not a sandbox

Section titled “eval, new Function, and vm are not a sandbox”

vm.createContext looks like it isolates code — it isolates globals, not the underlying object model. constructor.constructor reaches the real Function constructor from inside the “sandboxed” context, and that constructor is not scoped to the vm at all:

Node.js
Output (verified on Node 24)
escaped the vm context, read a value from the host process: v24.13.0

That one-liner used only this.constructor.constructor, no imports, nothing unusual — and it read process.version from the host process, something the “sandboxed” context object was never given a reference to. eval() and new Function() have no isolation story at all: both simply run in the calling scope with full privileges. If you need to run genuinely untrusted code, vm alone is documented by Node itself as insufficient — you need OS-level isolation (a separate process with the --permission model, a container, or a dedicated sandboxing project), not a JavaScript-level trick.

Most of a typical Node application’s code is not code the team wrote — it is node_modules. The practical checklist: npm audit for known vulnerabilities in the current dependency tree; a committed lockfile (package-lock.json) so “works on my machine” actually means the same dependency versions everywhere, not whatever ^ ranges happened to resolve to today; provenance attestations (npm audit signatures, packages published with --provenance) as an emerging way to verify a package was actually built from its published source rather than tampered with post-publish; and --ignore-scripts on install in CI/build environments where you do not need (and do not want to trust) arbitrary postinstall scripts from third-party packages to execute.

Think of a default Node process as a house with every door unlocked — not because anyone chose that, but because no one installed locks. --permission is choosing to lock every door and hand out keys only to the rooms a given process actually needs. vm/eval/new Function are not a lock at all — they are a curtain: it looks like a wall from the code’s own perspective, but anything that knows to reach past the curtain (constructor.constructor) finds the same open house behind it. Prototype pollution is a house where every room secretly shares one master blueprint (Object.prototype) — vandalize the blueprint once, and every room built from it, past and future, has the vandal’s change.

--permission’s allowlist model is real security, at the cost of having to enumerate what a process needs up front — for a large existing codebase, figuring out the complete list of paths/hosts/operations it actually touches is real, non-trivial work, and it is still labeled experimental. scrypt/pbkdf2 being deliberately slow and memory-hard is the entire point for password hashing, but that same cost makes them the wrong choice anywhere you are not specifically defending against offline guessing (e.g., hashing a high-volume, non-secret cache key) — use a fast hash there instead. Auditing dependencies (npm audit, pinned lockfiles, --ignore-scripts) has a real maintenance cost — someone has to review audit output and decide what is a real risk versus noise — but skipping it entirely means trusting every transitive dependency’s author, including ones whose packages you never chose directly.

  • “vm sandboxes untrusted code.” Node’s own documentation is explicit that vm is not a security mechanism for untrusted code — constructor.constructor (and other prototype-chain tricks) reach the real host globals from inside a vm context.
  • “Prototype pollution only affects the object that was merged into.” It pollutes Object.prototype itself, affecting every plain object in the process — including ones that existed before the pollution happened and ones created afterward, anywhere in the codebase.
  • “A committed lockfile alone is enough supply-chain protection.” It pins which versions you get, which is necessary, but says nothing about whether those versions are themselves safe — it does not replace npm audit, reviewing postinstall scripts, or provenance checks.

Generated code that builds a “restricted” evaluation environment is one of the more dangerous patterns to accept without scrutiny, because it often looks deliberately security-conscious while being fundamentally unsound.

🐛 Spot the bug

// AI-written "plugin system" -- runs user-submitted expressions
import vm from 'node:vm';
function runUserExpression(expr, allowedVars) {
const context = vm.createContext({ ...allowedVars }); // "sandboxed" -- only exposes allowedVars
return vm.runInContext(expr, context);
}
// Usage: runUserExpression('a + b', { a: 1, b: 2})
Show the answer

The comment claims this is “sandboxed” because context only explicitly contains allowedVars — but as demonstrated above, expr can reach far past that object via this.constructor.constructor(...)(), which returns the real Function constructor and runs with the full authority of the host process, regardless of what was or was not added to context. There is no fix that keeps using bare vm for genuinely untrusted expressions — the real options are: don’t accept arbitrary expressions from users at all (offer a fixed, safe set of operations instead), use a purpose-built expression-evaluator library with no access to Function/constructor by construction, or run the evaluation in a fully separate, OS-isolated process (a locked-down child process, a container) where escaping the “sandbox” only reaches that disposable process, not your main server.

Running node --permission script.js with no --allow-* flags at all, what happens when the script calls fs.readFileSync?
Why use scrypt/pbkdf2 instead of a fast hash like sha256 for storing passwords?
What is the actual risk here, if untrustedInput is {"__proto__": {"isAdmin": true}}?
function merge(target, source) {
  for (const key in source) target[key] = source[key]; // no key filtering
  return target;
}
merge({}, JSON.parse(untrustedInput));
Why does this return the host process's process.version, even though process was never added to the context?
const context = vm.createContext({ a: 1 });
vm.runInContext("this.constructor.constructor('return process.version')()", context);
What does a committed package-lock.json actually guarantee, and what does it NOT guarantee?