node:vm Is Not a Sandbox (And Neither Was vm2)
Every few months a product manager asks for the same feature, and it is always phrased innocently. Let users write a little JavaScript. A formula field. A webhook transform. A custom scoring rule. A plugin. An agent that writes code and then runs it. You go looking for the smallest thing that could possibly work, and Node's standard library appears to hand it to you already gift-wrapped: a built-in module, literally called vm, with a function literally called runInNewContext. The word sandbox even appears in the API surface. It would be unreasonable, at that point, not to feel a little bit lucky.
The Node documentation, to its considerable credit, tells you the truth in the first screen: the module "is not a security mechanism" and should not be used to run untrusted code. That sentence has been there for years. It is also, empirically, the most-skipped sentence in the Node docs, because the API works, the demo runs, the tests pass, and the tests pass specifically because they were written by someone who was not trying to get out.
This post is the technical version of that warning, for people who know V8 and want the actual mechanism rather than a scare quote: what node:vm really provides, why the escape class is structural instead of patchable, what vm2 was and what its ending teaches, the four ways an in-process sandbox hurts you even if escapes did not exist, and the ladder of boundaries that do work — in order, with honest costs.
What node:vm actually gives you
node:vm creates a new V8 context. That is the whole product, and it is worth being exact about what a context is, because the entire misunderstanding lives in this one distinction.
- A context (roughly, an ECMAScript realm) is a fresh global object with its own set of intrinsics — its own Object, its own Array, its own Function. Two contexts have different globalThis values. This is the same mechanism that makes an <iframe>'s Array !== the parent page's Array.
- An isolate is V8's unit of actual separation: one heap, one garbage collector, one set of compiled code, one thread of execution at a time. Many contexts can — and in node:vm's case, do — live inside a single isolate.
- A process is the OS's unit of separation: its own address space, its own file descriptors, its own resource limits, its own thing to kill.
- A kernel boundary is the hypervisor's unit of separation: the guest talks to its own kernel, and the host is exposed only through a hardware-mediated interface.
node:vm gives you the first one. Only the first one. The code you run in a new context shares your isolate, your heap, your garbage collector, your thread, your event loop, your process, your file descriptors, your environment variables, your network stack, and your kernel. It gets a different global object. That is the entire delta.
// UNSAFE. This is the shape that ships, and it is not a sandbox.
const vm = require("node:vm");
function runUserSnippet(source, input) {
const context = { input, result: null };
vm.createContext(context); // a fresh global object...
vm.runInContext(source, context, { // ...in the same isolate, the same
timeout: 1000, // process, the same event loop and
displayErrors: true, // the same heap as everything else.
});
return context.result;
}
// This passes every test you will think to write, because the person
// writing the tests is not the person you are defending against.
module.exports = { runUserSnippet };The escape shape, and why it is structural
Here is the class of escape, described precisely enough to be useful and deliberately not far enough to be a payload. There is no shortage of working ones on the internet; this post is not going to add another.
The guest starts in a context whose intrinsics are its own. Fine so far. But a sandbox that runs code and returns nothing is useless, so in practice you pass things in and get things out: an input object, a helper function, a callback, a Promise, an error. Every one of those is an object created in your realm, and every object in JavaScript carries its prototype with it. Once the guest holds a reference to any host-realm object — a plain object you passed as input, a function you exposed as a convenience, an Error your own code threw across the boundary, an array returned by a host helper — it can walk from that object to its prototype, from the prototype to its constructor, and from any host-realm function to that realm's Function constructor. And Function is a compiler: give it a string, get back a callable that runs in the realm that owns that constructor. Which is yours.
That is the whole trick, and it has a dozen surface forms. Prototype chains via __proto__ or constructor. Errors thrown out of guest code and caught by host code, or thrown by host code and caught by guest code — a stack trace is a rich object graph and Error.prepareStackTrace is a callback into your realm. Proxies that run guest code inside host traps at moments the host author never considered reentrant. Getters that fire during property access the host thought was a plain read. async/await, which hands the guest a scheduling seam in the middle of your own function. Every one of these is not a bug in node:vm. Every one is JavaScript working as designed, in a language where an object is a bag of references and a prototype is just another reference.
So the reason this is unfixable in-process is not that the maintainers are insufficiently clever. It is that the sandbox's job would be to guarantee that no host-realm reference is ever reachable from guest code, transitively, through every reflective and metaprogramming feature the language has — including the ones added in future ECMAScript versions and the ones V8 adds first. That is a whole-language invariant enforced by a library that does not control the language. Each individual hole is patchable. The supply of holes is not bounded by anything you own.
What happened to vm2
vm2 existed because everyone hit the wall above and wanted the wall to not be there. It was a serious, well-engineered attempt: build a hardened layer on top of node:vm that proxies everything crossing the boundary, so that guest code never touches a host object directly — only a membrane of proxies that sanitize what passes through. It was popular, it was widely embedded in products, and for a long time it was the answer people gave when you asked how to run untrusted JavaScript in Node.
It also accumulated a long run of sandbox-escape vulnerabilities, each one a new path through the membrane — a reflective feature, an error-handling seam, a proxy trap invariant, a host object that leaked through a path the membrane did not model. Each was reported, each was patched, and then another one arrived. In 2023 the maintainer did the honest and unusual thing: rather than continue shipping a security guarantee he had concluded could not be delivered, he discontinued the project and told people to stop treating it as a security boundary.
That deserves respect rather than a dunk, and the lesson generalizes beyond one library. The failure was not a lapse in engineering quality; it was a category error baked into the goal. An in-process JavaScript sandbox is asking a library to enforce an invariant over a language surface that keeps growing, against attackers who only need one path, in a runtime where every escape is total. The arms race is the product. If your isolation strategy has a changelog full of security fixes, that is not evidence of diligence — it is a measurement of how many paths existed that nobody had found yet.
Four failure modes that do not require an escape at all
Suppose, generously, that escapes were solved tomorrow. In-process execution would still be the wrong shape, for four reasons that have nothing to do with the prototype chain.
1. There is no CPU limit, only a synchronous timeout
The timeout option looks like a resource limit. It is not. It terminates synchronous execution of the script you launched, and that is the extent of its authority. Anything the guest schedules — a timer, a Promise continuation, a microtask, an I/O callback from any host function you kindly exposed — outlives the call and keeps running on your event loop afterwards, because it is your event loop. There was never a second one.
And even when the timeout does exactly what it says, notice what "working" means: your single thread was blocked for the full window while a stranger's code ran on it. Ten concurrent submissions with a one-second limit is ten seconds of an unresponsive server, entirely within spec, no exploit required. Node is single-threaded per process by design, and you handed that thread to the guest.
const vm = require("node:vm");
// `timeout` terminates SYNCHRONOUS execution. It is not a CPU budget.
// 1) Anything asynchronous simply outlives it. The call returns "in time";
// the work it scheduled keeps running on YOUR event loop afterwards.
vm.runInNewContext(
"setInterval(() => { let n = 0; for (let i = 0; i < 5e7; i++) n += i; }, 0)",
{ setInterval }, // one convenience global, one permanent tenant
{ timeout: 50 }
);
// 2) A time limit does not bound memory. This never has to finish to win.
vm.runInNewContext("const keep = []; for (;;) keep.push(new Array(1e6).fill(0));");
// FATAL ERROR: ... JavaScript heap out of memory
// -> the whole process dies, taking every unrelated in-flight request with it.
// 3) Even a timeout that works perfectly "works" by blocking your only
// thread for the entire window. Ten concurrent 1s submissions is ten
// seconds of dead server, no vulnerability required.2. There is no memory limit, because there is one heap
A context is not a heap. Guest allocations come out of the same V8 heap as your request handlers, your connection pool, and your caches, and they are collected by the same garbage collector. The guest does not need to allocate maliciously to hurt you — it only needs to allocate enthusiastically. When the heap hits its ceiling, V8 does not fail the guest's script; it aborts the process. Your incident channel will describe this as an OOM of unknown origin, and it will be right.
3. There is no filesystem or network boundary, only a naming convention
The guest gets a clean global object, which means it cannot type require or process and get yours — by name. It is running inside your process, which has your file descriptors, your environment (including every API key you exported), your outbound network, your cloud metadata endpoint, and your credentials on disk. All of that is one host-realm reference away. Since almost every real integration eventually exposes a helper — "they just need fetch", "they just need a logger", "they just need this one npm module" — the naming convention gets a hole punched in it by the feature roadmap, on purpose, by you.
4. Intrinsics are shared far more than you think
A fresh context has fresh intrinsics, so a guest that writes to Object.prototype pollutes only its own realm. Reassuring — right up until something crosses back. Objects created in the guest realm and returned to you carry guest prototypes into your code paths; objects you passed in carry your prototypes into theirs. Any shared reference — a mutable object you handed over, a Map you both hold, a module instance reachable from an exposed helper — is a channel for state corruption that requires no escape at all, just patience and a getter defined in the wrong place. And because everything is one heap and one thread, timing and memory pressure are shared side channels by construction.
The mechanisms, compared honestly
- node:vm — boundary: a new V8 context (global object) in the same isolate, process and event loop; stops code escape: no, and the docs say so explicitly; stops resource abuse: no — sync timeout only, no memory cap; caveat: it is a namespacing tool for trusted code, and it is genuinely good at that job.
- vm2 — boundary: a proxy membrane over node:vm, still same isolate and process; stops code escape: no — repeated escapes, discontinued by its maintainer; stops resource abuse: no, same event loop and heap; caveat: treat any remaining production usage as a live finding, not as legacy debt.
- isolated-vm — boundary: a separate V8 isolate — its own heap, its own GC, no shared intrinsics; stops code escape: mostly, for the JS-realm class, because there is no host object graph to walk unless you hand one over; stops resource abuse: memory yes (real limit), CPU partially (terminable, own thread); caveat: still one OS process and one kernel — a V8 memory-safety bug or anything you deliberately expose is still yours.
- Separate process + seccomp/cgroups — boundary: an OS process with a filtered syscall set and hard resource limits; stops code escape: it contains a V8 compromise inside a disposable process; stops resource abuse: yes — CPU, memory, PIDs, and you can just SIGKILL it; caveat: shared host kernel, so the full remaining syscall surface is the attack surface.
- WebAssembly + WASI (wasmtime and friends) — boundary: linear memory plus capability-based host imports — no ambient authority, no file or socket unless granted; stops code escape: strong for pure compute, and the guest cannot even name what it was not given; stops resource abuse: yes, via fuel/epoch interruption and memory limits; caveat: it is not a Linux process — "run this npm package with native deps" is awkward to impossible.
- Container (Docker, runc) — boundary: namespaces, cgroups, capabilities and seccomp over a shared host kernel; stops code escape: partially — it stops casual escapes, not a reachable kernel bug or a bad flag; stops resource abuse: yes, cgroups are real; caveat: one kernel bug or one --privileged from being no boundary at all.
- microVM (Firecracker) — boundary: its own guest kernel behind hardware virtualization via KVM, with a minimal virtio device model; stops code escape: yes for the whole in-process and shared-kernel class — an escape must break the hypervisor; stops resource abuse: yes, per-VM CPU and memory that cannot touch the host; caveat: a separate kernel per workload, so it is only practical if creation is cheap — which is the entire engineering problem.
What actually works, in order
The ladder below is ordered by strength, and every rung is a real improvement over the one beneath it. Pick by threat model, not by vibe.
A separate V8 isolate (isolated-vm)
isolated-vm is the honest in-process answer, and its honesty is exactly what makes it usable: nothing crosses the boundary implicitly. There is no shared object graph, so the prototype-walk class has nothing to walk. Values are copied, or passed as explicit References that you chose to create. You get a real memoryLimit enforced by a real separate heap, and execution that can be terminated for real.
import ivm from "isolated-vm";
// A separate V8 isolate: its own heap, its own GC, a real memory cap.
const isolate = new ivm.Isolate({ memoryLimit: 128 /* MB */ });
const context = await isolate.createContext();
// Nothing crosses implicitly. No require, no process, no host objects.
// Values are copied; anything richer is a Reference you chose to create.
await context.global.set("input", 42);
const script = await isolate.compileScript("input * 2");
const result = await script.run(context, { timeout: 1000 });
console.log(result); // 84
isolate.dispose();
// Bought: a heap boundary, a real memory limit, no shared intrinsics,
// terminable execution that does not block your main thread.
// Not bought: a process boundary, a syscall boundary, a kernel boundary,
// or any protection at all from the References you hand across.
// Every hole in this design is one you personally drilled.The remaining exposure is worth naming plainly. It is still your process — a memory-safety bug in V8 itself is a full compromise of everything in that address space, and the guest is running an attacker-chosen workload against a JIT, which is the most exploited class of software on the internet. It is still your kernel: no syscall boundary exists. And the API is powerful enough that a tired afternoon and one over-generous Reference reintroduces the whole problem. Use isolated-vm when the workload is small, computational, and you control every import. Do not use it as the outermost boundary for arbitrary code.
A separate OS process with seccomp and cgroups
Fork a child, drop privileges, apply a seccomp-bpf filter, put it in a cgroup with CPU and memory limits, hand it the code over a pipe, and kill it when you are done or bored. This is a genuine step change: the failure mode of a V8 exploit becomes a dead child process rather than a compromised server, and resource abuse becomes a number in a cgroup instead of an incident. It is also unglamorous, well-understood, and available on any Linux box. The ceiling is the shared kernel — every syscall you left allowed is attack surface, and a local privilege escalation reachable from the child is a host compromise.
WebAssembly with a capability-based host
wasmtime and its peers give you a deny-by-default guest: linear memory it cannot address outside of, imports it cannot call unless you granted them, no ambient filesystem, no ambient sockets, plus fuel or epoch-based interruption for compute limits. For pure computation — formulas, scoring, transforms, policy evaluation — it is an excellent and fast boundary. It is a poor fit for the request that actually shows up, which is usually "run this JavaScript, and also it does an npm install and reads a file." You can run a JS engine inside WASM, which is a real and increasingly practical pattern, at the cost of a second layer of runtime to operate. We compare this rung against microVMs in more depth in /blog/wasm-vs-microvm.
A container
Better than nothing, and much better than in-process. Namespaces limit what the guest sees, cgroups limit what it consumes, seccomp shrinks the reachable syscall set. But all of those mechanisms are implemented by the host kernel that the guest is also calling into, so the referee and the field are the same object. For arbitrary hostile code, a container is one reachable kernel bug — or one careless flag — away from not being a boundary. The long version is in /blog/why-docker-is-not-a-sandbox.
A microVM
This is the boundary you wanted when you typed runInNewContext. The guest gets its own kernel; the host exposes a hardware-mediated interface (KVM) plus a deliberately tiny virtio device model instead of the full Linux syscall ABI. Guest code that escapes V8 lands in its own kernel, and code that escapes that kernel is facing a hypervisor rather than your process. Resource limits are per-VM and enforced below the guest. Not unbreakable — hypervisor escapes exist and side channels cross VM boundaries in principle — but categorically stronger than anything sharing your address space, and the surface is small enough that it is a focused, well-audited target rather than a sprawling one.
The historical objection was cost: nobody was going to boot a VM per formula evaluation in 2015. That objection is what snapshot-restore removed.
The practical shape for "run this user's JavaScript"
The pattern is one disposable microVM per execution: create it, write the code in, run it, read stdout, destroy it. No pooling across trust boundaries, no reuse between users, no long-lived interpreter accumulating whatever the last caller left behind. PandaStack creates a sandbox by restoring a baked Firecracker snapshot rather than cold-booting — 179ms p50, 203ms p99, of which the snapshot restore step itself is about 49ms. A brand-new template cold-boots once in roughly 3 seconds and bakes its snapshot; every create after that takes the fast path. That number is the whole argument: at ~179ms, a fresh hardware-isolated kernel per execution stops being an architecture debate and starts being a function call.
import { Sandbox } from "@pandastack/sdk";
// One disposable microVM per execution: its own guest kernel, behind KVM.
const sbx = await Sandbox.create({ template: "base", ttlSeconds: 120 });
try {
await sbx.filesystem.write("/tmp/user.js", code); // whatever the user sent
const r = await sbx.exec("node /tmp/user.js");
console.log(r.stdout, r.stderr, r.exitCode);
} finally {
await sbx.kill(); // and the TTL reaps it even if this line never runs
}
// The code can walk every prototype it likes. The realm it reaches is
// inside a VM that exists for 120 seconds and shares nothing with you.The Python SDK is the same shape: from pandastack import Sandbox, then Sandbox.create(template="base", ttl_seconds=120), sbx.filesystem.write("/tmp/user.js", code), sbx.exec("node /tmp/user.js"), sbx.kill(). Each sandbox also gets its own network namespace with its own /30 subnet — 16,384 of them are pre-allocated per agent — so egress policy is per-execution rather than per-server, which matters because exfiltration does not require an escape and is the failure you are statistically most likely to actually suffer.
If you need state across steps within one task — a REPL session, an agent that iterates, a build that installs before it runs — snapshot or fork the sandbox instead of sharing one across callers. A same-host fork lands in 400–750ms via copy-on-write; cross-host is 1.2–3.5s. The rule that matters is not "never reuse a sandbox," it is "never let a sandbox outlive the trust boundary it was created for."
The short version
node:vm is a namespacing tool, and a good one — use it for configuration files you wrote, templating, test isolation, and other trusted-code jobs where a clean global is genuinely what you want. The moment the source string comes from a user, a customer, a pull request, or a language model, you are no longer namespacing, you are defending, and a new global object is not a defense. vm2 tried to make it one with more engineering effort than most of us would have spent, and the outcome was an honest retirement notice.
So skip the arms race. Put untrusted JavaScript somewhere that has its own kernel and its own lifetime, run it, take the output, and delete the whole environment. The worst case stops being an incident review and becomes a VM you were going to throw away 120 seconds later anyway. For the full decision framework, start at /blog/how-to-sandbox-untrusted-code; for the rung-by-rung version of the ladder above, /blog/code-isolation-hierarchy.
Frequently asked questions
Is node:vm safe for untrusted code?
No, and Node's own documentation says so directly — the module is not a security mechanism. It creates a new V8 context, meaning a fresh global object, inside the same isolate, heap, event loop and process as your application. Guest code that obtains a reference to any host-realm object can walk prototype and constructor chains back into your realm and compile code that runs outside the context. It also has no memory limit and no real CPU limit. Use it for trusted code where a clean global is convenient; never as a boundary against code you did not write.
Is vm2 still maintained?
No. vm2 was a serious attempt to harden node:vm with a proxy membrane so guest code never touched host objects directly, and it was widely deployed. It suffered a long run of sandbox-escape vulnerabilities, each patched and each followed by another, and in 2023 the maintainer discontinued the project rather than keep promising a guarantee he had concluded could not be delivered. If vm2 is still in your dependency tree as a security boundary, treat that as an open finding rather than legacy debt. The lesson is structural: an in-process JavaScript sandbox is an arms race, not a bug you can finish fixing.
Is isolated-vm a real sandbox?
It is a real memory boundary and the best in-process option, but it is not a full sandbox. isolated-vm runs guest code in a separate V8 isolate with its own heap, its own garbage collector and no shared intrinsics, so there is no host object graph to walk and memory limits are actually enforced. What it does not give you is a process boundary, a syscall boundary or a kernel boundary — a V8 memory-safety bug is still a compromise of your address space, and every Reference you hand across is a hole you designed. Good for small controlled compute; not the outermost boundary for arbitrary code.
How do I run user-submitted JavaScript safely?
Run it outside your process, in an environment you can afford to destroy. In rough order of strength: a separate OS process with seccomp and cgroup limits, WebAssembly with a capability-based host for pure compute, a container, and a microVM with its own guest kernel behind hardware virtualization. Combine whichever you pick with ephemerality — one fresh environment per execution, never reused across users — plus default-deny egress and no host credentials in the environment. PandaStack's version is a disposable Firecracker microVM per run, created from a snapshot in about 179ms and killed when the run ends.
Does a timeout protect the event loop?
Not meaningfully. The timeout option terminates synchronous execution of the script you launched, and nothing more. Anything the guest schedules — a timer, a promise continuation, a microtask, an I/O callback from any host function you exposed — keeps running afterwards on your event loop, because there is only one and it is yours. Timeouts also do not bound memory, so allocation-heavy code can OOM the whole process well before any deadline matters. And even a timeout that fires correctly means a stranger's code blocked your only thread for the full window. Real CPU limits need a separate thread, process or VM.
Keep reading
- How to Sandbox Untrusted & AI-Generated Code — The decision framework: threat models first, then boundaries.
- The Code Isolation Hierarchy — Every rung from bare process to confidential VM, with the real costs.
- WASM vs microVM — When a capability-based WASM host beats a VM — and when it can't.
- Sandboxing an Untrusted npm install — The other half of running strangers' JavaScript: the dependency tree.
49ms p50 cold start. Fork, snapshot, and scale to zero.