Simon Willison recently rounded up current JavaScript sandboxing research, which is worth reading as a survey. But the survey raises a question the article only partially answers: why is this still an unsolved problem in 2026, when JavaScript has been the world’s most deployed language for three decades?
The answer lives in the language’s design. JavaScript was built for mutual distrust from the start, but the primitives for actually isolating code have been bolted on after the fact, piecemeal, and every approach has found a way to leak.
The Structural Problem
JavaScript’s execution model has a few properties that make sandboxing surprisingly hard.
First, every function carries a reference to the global object through its closure chain. Even a completely innocuous-looking piece of code can climb the prototype tree to reach Function, and from Function you can reach everything:
// Inside a "sandboxed" context
const escape = ({}).constructor.constructor;
escape('return process')(); // Reaches Node.js process object
This is not an obscure exploit. It is the fundamental shape of JavaScript’s object model. The global Function constructor is reachable from any object through .__proto__.constructor.constructor, and Function can execute arbitrary strings as code. Any sandbox that does not address this is not a sandbox.
Second, the intrinsics, meaning Object.prototype, Array.prototype, Promise, and so on, are shared across all code running in the same realm unless you take explicit steps to freeze them. A piece of untrusted code can monkey-patch Array.prototype.map and affect every subsequent array operation in the process.
Third, eval and new Function() are first-class features of the language, not legacy cruft you can simply disable. Removing them breaks real code.
What Failed: node:vm and vm2
Node.js has had a vm module since version 0.1. It creates a new V8 context with a fresh global object. It looks like a sandbox:
const vm = require('node:vm');
const context = vm.createContext({ result: undefined });
vm.runInContext('result = 1 + 1', context);
The Node.js documentation is explicit: “The vm module is not a security mechanism. Do not use it to run untrusted code.” That caveat has been in the docs for years. Code running inside a vm context can still reach the host process via the prototype chain, via shared intrinsics, and through a half-dozen other vectors.
vm2 was a popular npm package that tried to patch over these holes. It wrapped node:vm with a Proxy-based approach that intercepted property access and tried to block escape attempts. For several years it was the de facto answer to “how do I run untrusted code in Node.js.”
Then the CVEs started. CVE-2023-29017, CVE-2023-32313, CVE-2023-32314: three critical remote code execution vulnerabilities in quick succession, each allowing complete escape from the sandbox. The maintainer deprecated the package in May 2023, writing that the project “is no longer maintained and the authors recommend looking for alternatives.”
This was not a failure of implementation. The Proxy-based approach was reasonably clever. The problem is that JavaScript’s attack surface is large enough that a purely defensive approach, intercepting bad access patterns, will always miss something.
What Works: V8 Isolates
The most robust approach in production today is process or isolate-level separation. isolated-vm exposes V8’s Isolate API to Node.js. A V8 isolate is a completely separate heap and execution context; there is no shared memory between isolates unless you explicitly transfer it using structured clone or transferable objects.
import ivm from 'isolated-vm';
const isolate = new ivm.Isolate({ memoryLimit: 32 });
const context = await isolate.createContext();
const jail = context.global;
await jail.set('log', new ivm.Reference(console.log));
const result = await isolate.compileScript('1 + 1');
await result.run(context);
This is what Cloudflare Workers uses. Each Worker runs in its own V8 isolate. The security model is not “we blocked all the bad APIs”; it is “there is no shared memory to exploit.” Cold start latency is measured in microseconds rather than milliseconds because you are not spawning a process, just allocating a new isolate.
The tradeoff is complexity. The API for passing data between your host process and an isolate is explicit and somewhat verbose. Anything that crosses the isolate boundary must be serializable. This is by design: the boundary is the point.
The Principled Approach: SES and the Compartment Proposal
Parallel to the isolate work, a group at Agoric has spent years on a different approach: hardening the JavaScript language itself so that a sandbox can be constructed from within a single realm.
Hardened JS, formerly called SES (Secure ECMAScript), works by calling lockdown() at startup. This freezes all primordials: Object.prototype, Array.prototype, every built-in constructor, every intrinsic. Once frozen, no code can modify shared prototypes, which eliminates an entire class of attacks.
import 'ses';
lockdown(); // Freezes the primordial objects
const compartment = new Compartment({
globals: {
Math,
console: harden({ log: console.log })
}
});
const result = compartment.evaluate(`Math.sqrt(4)`);
Compartment creates an isolated module evaluation environment with its own global scope. The key insight is that by freezing the intrinsics first, the prototype chain attack is neutralized: there is nothing useful to reach even if code climbs to Object.prototype.
This work fed directly into the TC39 Compartment proposal, which is working toward standardizing Compartment as a first-class language feature. The proposal is designed to complement, not replace, isolate-based approaches: it handles same-process module isolation where you want lightweight boundaries, not full process separation.
Deno’s Different Model
Deno takes a third approach. Rather than sandboxing JavaScript’s execution model, it restricts what the runtime will do on behalf of JavaScript code. You cannot open a file unless you pass --allow-read. You cannot make network requests without --allow-net.
This is a capability-based model, and it works well for a specific use case: running scripts where you trust the code’s logic but want to constrain its access to system resources. It is less useful if you genuinely distrust the code itself, because the JavaScript execution model is unconstrained within those capability boundaries.
The WebAssembly Option
For cases where you need strong isolation without the overhead of a full process, there is a newer option: compile a lightweight JavaScript engine to WebAssembly and run untrusted JS inside the Wasm sandbox.
QuickJS, Fabrice Bellard’s compact JS engine, compiles to Wasm cleanly. The resulting bundle is a few hundred kilobytes. WebAssembly’s linear memory model provides genuine isolation: Wasm code cannot reach outside its memory region. Combined with WASI for capability-based I/O, you get a two-layer sandbox: Wasm for memory isolation, WASI for resource access control.
The cost is performance. Interpreted JS inside a Wasm-compiled interpreter is roughly two orders of magnitude slower than native V8. For most plugin or scripting use cases that is acceptable; for high-throughput request handling it is not.
Choosing the Right Approach
These are not competing solutions to the same problem. They address different threat models.
If you are running high-volume untrusted code (a serverless platform, a plugin host for a popular tool), V8 isolates via isolated-vm or a Workers-style runtime give you the best isolation-to-overhead ratio. The security model is simple: separate heaps, no shared memory.
If you are building a module system where you want to constrain what code can import or what globals it can access, the SES Compartment model is more appropriate. It is same-process, lower overhead, and the TC39 standardization path means it will eventually be available without a polyfill.
If you need portability and strong memory isolation and performance is secondary, QuickJS-in-Wasm is worth serious consideration, particularly as the WASI ecosystem matures.
What you should not use for security is node:vm, any Proxy-based wrapper around it, or any approach that relies on intercepting dangerous patterns rather than structurally preventing access. The JavaScript attack surface is large enough that pattern-based defenses will reliably miss things, as the vm2 history demonstrated.
The underlying theme across all of this research is that JavaScript sandboxing requires thinking about the language’s object model from first principles. The approaches that work do so because they address that model directly, either by freezing it (Hardened JS), replacing it (V8 isolates), or executing it inside a memory-isolated container (Wasm). The approaches that failed all tried to paper over it.