· 7 min read ·

The Embeddable Language Problem: What JavaScript Sandboxing Research Keeps Rediscovering

Source: simonwillison

Every platform that allows user-defined behavior eventually confronts the same problem: you want to let users write logic without letting them compromise your system. Game engines solved this in the 1990s by embedding Lua. Discord bots want plugin systems. CI platforms want user-defined build steps. AI coding agents want to execute generated code. The surface area of “run user-supplied code” has only grown.

JavaScript is the obvious choice for most of these systems. The language is everywhere, developers already know it, and the tooling ecosystem is vast. What Simon Willison’s recent research roundup on JavaScript sandboxing makes clear is that this obvious choice comes with a structural complication the ecosystem has been working around, incompletely, for years.

The Promise of vm and What It Delivered

Node.js ships with a module called node:vm. It creates script execution contexts with their own globals. The API looks like a sandbox:

const vm = require('node:vm');
const ctx = vm.createContext({ result: null });
vm.runInContext('result = 1 + 1', ctx);

The Node.js documentation has, for years, contained this sentence: “The node:vm module is not a security mechanism.” It is a code organization tool, not an isolation primitive. The sandbox appearance is real; the security boundary is not.

The vm2 library existed to fix this. It wrapped node:vm and added shielding against prototype chain escapes. It accumulated millions of weekly downloads, and maintainers of unrelated packages quietly relied on it for any feature that touched user-provided JavaScript. Then in spring 2023, two critical vulnerabilities arrived within weeks of each other. CVE-2023-29199 and CVE-2023-30547, both rated 9.8 CVSS. Both were sandbox escapes. Both exploited the prototype chain, not implementation bugs. The vm2 maintainers deprecated the project entirely, writing that the problem is fundamentally unsolvable at the pure language level.

That conclusion deserves attention.

Why the Prototype Chain Is Not a Security Boundary

JavaScript’s objects share a small set of built-in prototypes. Object, Function, Array, and the other primordials are not just types; they are specific objects that live in the runtime and are reachable from almost any value. The canonical escape path is short:

({}).constructor.constructor('return process')()

({}).constructor gives you Object. Object.constructor gives you Function. The outer Function constructor can construct code that runs in the outer context. Any containment approach that works by intercepting property access at runtime has to cover every path along every prototype chain, including paths through Proxy traps, Symbol protocols, generator protocol objects, and iterator result shapes. The attack surface is not a specific feature; it is the mechanism of object composition itself.

This is why every sandbox that works at the pure language level eventually fails. You cannot enumerate the escape paths in advance, because the language was designed for extensibility, not confinement.

The ShadowRealm Misunderstanding

The TC39 ShadowRealm proposal reached Stage 3 in 2021 and has been advancing since. It is commonly described as JavaScript’s native sandbox. It is not. A ShadowRealm provides a fresh global object and a separate set of primordials, so code inside has its own Object, its own Array. But both realms share the same agent and the same event loop. Values passed across the boundary immediately expose the host realm’s constructors.

The proposal documentation is explicit about this: ShadowRealm is designed for module isolation and clean test environments, not hostile code. If you trust the author of the code but want a pristine global scope, it is the right tool. If the author is adversarial, it provides no meaningful protection. The name has caused more confusion than the feature itself.

What Actually Works, and Why It Looks Like Lua

Agoric’s SES (Secure ECMAScript) takes a different approach from any other language-level attempt. Rather than trying to catch escape attempts, it removes the mutable state that makes escapes useful:

import 'ses';
lockdown(); // freezes Object.prototype, Function.prototype, all primordials

const c = new Compartment({ globals: { myAPI: safeAPI } });
c.evaluate(userCode);

After lockdown(), every primordial is frozen. There is nothing to navigate to that can be mutated. The Compartment then provides a controlled evaluation environment where you explicitly decide what the user can access. SES works because it changes the invariant rather than patrolling at runtime.

The limitation is ecosystem friction. lockdown() must run before any other code in the process. Libraries that mutate primordials at startup, a pattern common in older npm packages, break. Adopting SES requires committing to its model completely, which is why Agoric builds their entire Endo framework on it rather than treating it as an add-on to existing Node.js code.

The other approaches that hold up under scrutiny all use a separate engine. Cloudflare Workers runs each worker in its own V8 Isolate, a fully separate instance of the V8 engine with its own heap, GC, and primordials. Code in one isolate is physically incapable of reaching objects in another because there is no shared prototype chain to traverse. The isolated-vm package brings this primitive to Node.js through native bindings, with roughly 128KB memory overhead per isolate and explicit typed channels for passing values across the boundary.

And then there is QuickJS.

QuickJS is a compact, embeddable JavaScript engine written by Fabrice Bellard, the engineer also behind QEMU and FFmpeg. It implements ES2023, sits at around 200,000 lines of C, and compiles to roughly 1.2MB. When embedded in a host application, it exposes only what the host wires up through the C API. No process, no require, no filesystem access unless you provide them explicitly. The isolation boundary is the FFI layer, which is exactly the same model that made Lua the dominant choice for game scripting across three decades.

The quickjs-emscripten package compiles QuickJS to WebAssembly and wraps it for browser and Node.js use. WebAssembly’s linear memory model means the QuickJS heap is a bounded region that cannot access the host JavaScript heap by construction. You get both the embedding model (explicit API surface) and the memory model (structural separation). The result is a reasonable sandboxing answer for the majority of extensibility use cases that do not require full Node.js compatibility.

This is, structurally, the same answer the game industry arrived at in the 1990s. You do not try to sandbox a general-purpose language designed for a different context. You embed a minimal engine designed for embedding, expose a narrow API, and accept that user code cannot do anything you did not explicitly wire up. Lua’s success was not incidental; it was designed for this use case from the beginning, with a C API as a first-class concern. QuickJS via WebAssembly is the modern version of the same thesis applied to JavaScript.

The V8 Sandbox Is a Different Problem

One development that Willison’s roundup touches is the V8 in-engine sandbox, which graduated from experimental to production default in Chrome 123 in April 2024 and was added to Google’s Vulnerability Reward Program as its own category. This is easy to conflate with the user-code sandboxing question, but it addresses something different.

The V8 sandbox is about limiting what happens when the V8 engine itself is compromised. It uses pointer compression and indirection tables (the External Pointer Table for out-of-sandbox references, the Code Pointer Table for JIT-compiled code) to raise the cost of converting a heap corruption bug into arbitrary code execution. Before the sandbox, a single type confusion in V8 was frequently sufficient for full renderer compromise. After it, the same bug requires a second, independent exploit to escape the sandbox. That is a meaningful shift in exploit economics, but it sits below the layer where user-code isolation operates.

For the purposes of running untrusted JavaScript, the V8 sandbox does not change what you need to do. V8 Isolates for process-level separation, or QuickJS via WASM for embedded execution, remain the right choices depending on your requirements.

The Research Landscape in 2026

Willison’s roundup covers the full range of current approaches, and the pattern across them is consistent: the techniques that provide real security guarantees all use architectural separation rather than language-level interception. V8 Isolates provide separate engine instances. SES provides frozen primordials that remove the mutable state worth reaching. QuickJS provides a separate engine with an explicit API boundary. WebAssembly provides linear memory isolation. Firecracker microVMs provide hardware-level isolation for the cases where the trust boundary needs to be even stronger.

None of them make it safe to run arbitrary code from npm inside your process. That remains unsolved, not because of insufficient research effort, but because the question asks JavaScript to be something it was not designed to be.

For most extensibility use cases, that constraint is fine. You do not need npm compatibility in a plugin system or a Discord bot scripting layer. You need a predictable execution environment with a controlled API surface. QuickJS or an embedded engine delivers that. The research has converged on this position; it took a while to get there because the npm ecosystem made it tempting to reach for general-purpose Node.js instead. Game developers never had that temptation. Lua was always minimal by design, and that design decision looked like a limitation until everyone else ran into the walls it was avoiding.

Was this interesting?