Run untrusted JavaScript where escaping it does not matter
Node.js 22 on a dedicated virtual machine: npm, native module builds, child processes, the filesystem — all of it real, none of it yours. When the sandbox expires, the machine is destroyed.
The in-process options, honestly ranked
JavaScript makes in-process sandboxing look achievable in a way that other languages do not, because V8 already has the concept of a separate context. That appearance has produced a long history of libraries that were treated as security boundaries and were not.
node:vm — not a boundary
The built-in module creates a new V8 context inside the same isolate, sharing the heap and the
object graph. Node's documentation states plainly that it is not a security mechanism. Any reference that
leaks across the boundary — a prototype, a thrown error, an argument object — is a path back to the host
realm's Function constructor.
vm2 — the cautionary tale
vm2 was a serious attempt to make that boundary hold, with proxy-based interception of every crossing.
It was escaped repeatedly. CVE-2023-37903, a critical sandbox escape via the custom-inspect symbol and
WebAssembly.compileStreaming, was published with no fix available; the maintainer
discontinued the project in July 2023 and pointed users at isolated-vm. Additional escapes have surfaced
since. The takeaway is architectural: defending a shared heap against an attacker who can execute
arbitrary JavaScript inside it is a losing position.
isolated-vm — right tool, narrow job
A separate V8 isolate with its own heap, its own garbage collector and an explicit transfer boundary for values. This is the correct choice for evaluating small untrusted expressions in-process — a formula in a spreadsheet cell, a user-supplied predicate. It gives you nothing at the OS level: no filesystem restriction, no network restriction, no defence against a V8 memory-safety bug. And it cannot help you at all if the untrusted code's whole purpose is to install packages and touch files.
Once the requirement includes npm install, native modules, or spawning processes — which it
does the moment a coding agent is involved — the boundary has to be below the runtime. That means a
machine.
A Node job in a sandbox
const API = "https://sandbox-as-a-service.com/v1";
const headers = {
Authorization: `Bearer ${process.env.AAS_API_KEY}`,
"Content-Type": "application/json",
};
const { id } = await fetch(`${API}/sandboxes`, {
method: "POST",
headers,
body: JSON.stringify({ size: "small", timeout_minutes: 20 }),
}).then((r) => r.json());
const exec = (command, timeout_ms = 60000, env = {}) =>
fetch(`${API}/sandboxes/${id}/exec`, {
method: "POST",
headers,
body: JSON.stringify({ command, timeout_ms, cwd: "/home/sandbox", env }),
}).then((r) => r.json());
try {
// Native bindings build here: build-essential and python3 are present.
await exec("npm init -y && npm install --silent sharp", 420000);
// Write the untrusted script with a quoted heredoc — no shell expansion.
await exec(`cat > job.mjs <<'JS'
import sharp from "sharp";
const meta = await sharp("in.png").metadata();
console.log(JSON.stringify({ width: meta.width, height: meta.height }));
JS`);
const r = await exec("node job.mjs", 120000, { NODE_OPTIONS: "--max-old-space-size=3072" });
if (r.exit_code !== 0) throw new Error(r.stderr);
console.log(JSON.parse(r.stdout));
} finally {
await fetch(`${API}/sandboxes/${id}`, { method: "DELETE", headers });
}SBX=$(curl -sX POST https://sandbox-as-a-service.com/v1/sandboxes \
-H "Authorization: Bearer $AAS_API_KEY" -H "Content-Type: application/json" \
-d '{"size":"small","timeout_minutes":20}' | jq -r .id)
# Runtime check
curl -sX POST https://sandbox-as-a-service.com/v1/sandboxes/$SBX/exec \
-H "Authorization: Bearer $AAS_API_KEY" -H "Content-Type: application/json" \
-d '{"command":"node --version && npm --version"}'
# An untrusted script, hard-stopped after 5 seconds
curl -sX POST https://sandbox-as-a-service.com/v1/sandboxes/$SBX/exec \
-H "Authorization: Bearer $AAS_API_KEY" -H "Content-Type: application/json" \
-d '{"command":"node -e \"while(true){}\"","timeout_ms":5000}'
# -> {"stdout":"","stderr":"","exit_code":124,"duration_ms":5001,"truncated":false}
curl -sX DELETE https://sandbox-as-a-service.com/v1/sandboxes/$SBX \
-H "Authorization: Bearer $AAS_API_KEY"
Notes for Node workloads
Heap limits are not memory limits
V8's default old-space limit is set from the machine it starts on, and it is not the same as the
sandbox's memory. A job that dies with an out-of-memory error from V8 needs
--max-old-space-size raised via NODE_OPTIONS; a job killed with exit code 137
hit the kernel's OOM killer and needs a larger sandbox size instead. Distinguishing the two saves a lot
of pointless debugging, especially when a model is reading the error.
npm is the slow step
Installing a dependency tree, particularly one with native builds, dominates the runtime of a short job.
Create one sandbox per session and reuse it across many exec calls rather than creating a
fresh machine per command — npm's cache lives on the sandbox disk and pays off within a session.
Timeouts actually work
timeout_ms kills a real process. A synchronous infinite loop, a promise that never settles,
a native call that blocks — all of them end when the timeout fires, returning what was produced so far.
In-process sandboxes cannot make that promise for synchronous code, since the loop starves the timer
that was supposed to stop it.
Questions
Is node:vm a sandbox?
No, and Node's own documentation says so directly: the vm module is not a security mechanism and must not be used to run untrusted code. It creates a separate V8 context, not a separate privilege domain. Escaping it is a well-known exercise — reach any object that came from the host realm, walk to its constructor, and you have the host's Function.
What about vm2?
vm2 spent years hardening exactly that boundary and repeatedly lost. CVE-2023-37903 is representative: a sandbox escape through the nodejs.util.inspect.custom symbol combined with WebAssembly.compileStreaming, rated critical with no fix at the time. Its maintainer discontinued the project in July 2023 and recommended migrating to isolated-vm, and further escapes have been reported since. The pattern is the lesson, not any individual CVE.
Then is isolated-vm enough?
It is a genuinely stronger design — a separate V8 isolate with its own heap, rather than a context sharing one — and it is the right tool for evaluating small, pure, untrusted expressions in-process. What it does not give you is an operating-system boundary: no filesystem isolation, no network isolation, no protection against a native module or a memory-safety bug in V8 itself. If untrusted code needs npm install, fs, or a child process, you need an OS-level boundary.
Can I npm install packages with native bindings?
Yes. build-essential and Python 3 are pre-installed, which covers the node-gyp toolchain that packages like sharp, better-sqlite3 and canvas need when a prebuilt binary is not available. Give the install a generous timeout_ms: a native rebuild can take minutes and will otherwise be killed halfway through.
Which runtime is installed?
Node.js 22, with npm. Deno, Bun and other Node versions install fine inside the sandbox — an npm i -g n or the vendor's install script both work — so treat the pre-installed runtime as a fast default rather than a constraint.
How do I stop an infinite loop?
Set timeout_ms on the exec call. Because the process is a real OS process rather than a construct inside your own event loop, killing it is reliable — no cooperation from the running script is required. This is a concrete advantage over in-process sandboxes, where a tight synchronous loop can block the very code that is supposed to interrupt it.
Run Node somewhere disposable
npm install anything, break anything. The machine is gone when the timeout fires.