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:
node read-secret.jstop secretnode --permission read-secret.jsError: Access to this API has been restricted. Use --allow-fs-read to manage permissions. code: 'ERR_ACCESS_DENIED', permission: 'FileSystemRead', resource: './secret.txt'node --permission --allow-fs-read=./secret.txt read-secret.jstop secretThis 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:
Output (verified on Node 24)
API_KEY without --env-file: undefinednode --env-file=.env read-env.jsAPI_KEY is super-secret-valueThe 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.
node:crypto, used correctly
Section titled “node:crypto, used correctly”Three specific, common mistakes and their fixes, all real behavior on Node 24.13:
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: truescrypt/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:
Output (verified on Node 24)
innocentObject.isAdmin (should not exist): trueThe 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:
Output (verified on Node 24)
escaped the vm context, read a value from the host process: v24.13.0That 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.
Dependency supply chain
Section titled “Dependency supply chain”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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”--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.
Common Misconceptions
Section titled “Common Misconceptions”- “
vmsandboxes untrusted code.” Node’s own documentation is explicit thatvmis 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.prototypeitself, 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, reviewingpostinstallscripts, or provenance checks.
Reading AI-generated code
Section titled “Reading AI-generated code”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 expressionsimport vm from 'node:vm';
function runUserExpression(expr, allowedVars) {const context = vm.createContext({ ...allowedVars }); // "sandboxed" -- only exposes allowedVarsreturn 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.