· 7 min read ·

Why JavaScript Is So Hard to Sandbox, and What People Are Actually Doing About It

Source: simonwillison

Running untrusted JavaScript in a controlled environment is one of those problems that looks tractable until you get close enough to see all the ways the language fights you. Simon Willison has been cataloging JavaScript sandboxing research as this area has become increasingly important, and the state of the ecosystem is genuinely interesting: there is no single right answer, several principled approaches are converging, and the history of failed attempts explains why.

The vm2 Postmortem

For years, vm2 was the go-to Node.js sandboxing library. It wrapped Node’s built-in vm module and tried to close the gaps that made raw vm.runInNewContext() trivially escapable. The core problem is that Node’s vm module was explicitly never designed as a security boundary. It creates a new V8 context with a fresh global object, but the JavaScript engine underneath is shared, the prototype chain of built-in objects is shared, and V8 exposes hooks that allow code in one context to reach into another.

vm2 tried to wrap all of this with Proxy objects and careful introspection. It worked well enough that a lot of production systems depended on it. Then in April 2023, CVE-2023-29017 demonstrated a complete sandbox escape via Error.prepareStackTrace, a V8-specific hook that fires when a stack trace is generated. The fix was followed almost immediately by CVE-2023-30547, a second critical escape through a closely related path. The maintainers deprecated vm2 entirely in May 2023, with a note that the approach was fundamentally unsound.

The lesson from vm2 is not that the authors were careless. The lesson is that trying to sandbox JavaScript from within JavaScript is a losing game. The language has too many reflection surfaces, too many engine-specific hooks, and too many ways for code to traverse the prototype chain to reach host objects.

isolated-vm and the V8 Isolate Boundary

The most robust option in the Node.js ecosystem today is isolated-vm, a native addon that exposes V8’s Isolate API directly. Where Node’s vm module creates a new context within the same isolate (same heap, same GC, same intrinsics), isolated-vm creates a genuinely separate V8 isolate, which is the same unit of isolation that Chrome uses between renderer processes.

The security model is qualitatively different. Objects cannot leak across isolate boundaries by reference. Values must be explicitly transferred, copied, or passed through a structured-clone-like mechanism. The attack surface shifts from the JavaScript language surface to the bridge code between isolates, which is finite and auditable.

The practical cost is complexity. Passing a callback from an isolate to host code requires wrapping it in a Reference object and calling it through a controlled channel. Async operations require careful marshaling. The native addon also means deployment is more complex than a pure-JavaScript package, which matters for serverless environments and edge runtimes.

Cloudflare Workers uses a conceptually similar architecture with their workerd runtime, which is open-source. Each worker gets a separate V8 isolate, cold-start latency is managed through snapshot-based initialization, and the bridge to host capabilities is explicit and controlled through a capability-based API.

What ShadowRealm Actually Does (And Doesn’t Do)

The TC39 ShadowRealm proposal reached Stage 3 and was implemented in Chrome. The API is clean:

const realm = new ShadowRealm();
const result = realm.evaluate(`1 + 2`);
// result === 3

Each ShadowRealm gets its own global object and its own module graph. Code in one realm cannot directly access variables from another realm. This is genuinely useful for plugin isolation, test runner environments, and bundler evaluation, where you want module-graph isolation without caring about security.

The proposal explicitly states it is not a security mechanism. The reasons are instructive. Objects returned from realm.evaluate() are live references, not copies. Intrinsics like Array, Object, and Function are per-realm, which helps, but there are enough sharing points that a determined attacker can traverse back to the host realm. The spec punts security to implementations, which means browser implementations can add process-level isolation if they choose, but JavaScript-embedded ShadowRealm in Node.js provides no security guarantee at all.

SES: The Serious Attempt at Language-Level Sandboxing

The most principled approach to JavaScript sandboxing comes from the work around Secure EcmaScript (SES), developed primarily by Mark Miller, Kris Kowal, and contributors at Agoric and Salesforce. The central insight is that JavaScript can be made safe if you start from a foundation of object-capability security, where every capability must be explicitly granted and no ambient authority exists.

The ses package (part of the Endo monorepo) provides two key primitives:

import 'ses';
lockdown(); // Freezes all shared intrinsics

const c = new Compartment({
  globals: { console }, // Only grant what you explicitly pass
});
c.evaluate(`console.log('hello')`);

lockdown() freezes all built-in prototypes and intrinsics, which closes the prototype-chain traversal attacks. Compartment provides an isolated evaluation environment where the only globals available are those you explicitly provide. Because the intrinsics are frozen at the host level, there is no path from compartment code to mutate or escape through them.

This approach is used in production by Agoric’s smart contract platform and by MetaMask through LavaMoat, which uses SES compartments to isolate npm dependencies from each other. The threat model for MetaMask is exactly the kind of problem SES was designed for: running third-party JavaScript (wallet extensions, dapp integrations) where supply chain compromise is a real concern.

The TC39 Compartment proposal, which standardizes the Compartment abstraction, is in early stages but would bring this security model into the language spec itself.

WASM-Based Approaches: Run a Different Engine

A fundamentally different strategy is to compile a JavaScript engine to WebAssembly and run untrusted code inside that. The WASM sandbox provides the isolation; the inner engine never has direct access to the host runtime’s objects or capabilities.

QuickJS, Fabrice Bellard’s compact ES2023-compatible engine written in C, is the most common target for this approach. The quickjs-emscripten library compiles QuickJS to WASM and wraps it with a TypeScript API:

import { getQuickJS } from 'quickjs-emscripten';
const QuickJS = await getQuickJS();
const vm = QuickJS.newContext();
const result = vm.evalCode(`1 + 2`);

Shopify uses a similar approach through Javy, which compiles JavaScript to WASM via QuickJS for running merchant plugins safely. The security model is clean: the WASM module can only access what the host explicitly passes through the WASM interface, which is a narrow, typed channel.

The cost is performance and API coverage. QuickJS is a complete ES engine but it is not V8, so JIT-heavy workloads run much slower. More practically, browser APIs (fetch, DOM, Web Crypto) are not available inside a QuickJS-in-WASM context unless you implement them as host functions bridged through the WASM interface.

Boa, a JavaScript engine written in Rust, is another candidate for embedding. The Rust implementation provides memory safety guarantees that limit host-corruption attack classes, and the sandboxing story is cleaner because there is no C FFI risk from extensions. Boa is still working toward full ES2022+ compliance, but it is advancing steadily.

The Architectural Trade-off Space

These approaches form a rough spectrum. The Node.js vm module is the fastest and most compatible but provides no security at all. isolated-vm provides strong V8 isolate boundaries at the cost of a native addon and careful bridge design. SES compartments provide language-level security guarantees at the cost of requiring lockdown() to run at startup (which affects all code in the process) and some incompatibility with libraries that rely on mutable globals. WASM-embedded engines provide the strongest isolation but sacrifice performance and API surface.

For most use cases, the right choice depends on the threat model. If you need to run plugins from trusted developers who might write buggy code but are not adversarial, ShadowRealm or a simple worker thread provides enough isolation. If you are running arbitrary third-party code where active exploitation is a concern, isolated-vm or a WASM-embedded engine is appropriate. If you need security guarantees that survive supply chain attacks within the same process, SES with lockdown() is the only serious option.

What the last few years have clarified is that there is no shortcut. The vm2 failure demonstrated that you cannot sandbox JavaScript from within JavaScript without engine-level support. Every approach that has held up under scrutiny requires either dropping to a layer below the JS engine (V8 isolates, WASM), or working with the object-capability model at the language level (SES), or accepting that your isolation boundary lives at the process level (worker threads, Deno’s permission model).

The TC39 Compartment proposal, if it advances, would be significant because it would put the SES model into the language spec and allow engines to optimize it. Right now, lockdown() in the SES shim does a lot of work at runtime that a native implementation could do at parse time. A standardized Compartment with engine support could close the gap between security and performance that currently makes SES a hard sell outside of security-critical applications.

Until then, the ecosystem answer is fragmented but improving, and the research that continues to surface sandbox escape vectors is, despite being uncomfortable, the only thing keeping these approaches honest.

Was this interesting?