You have to run untrusted JavaScript. A VM sandbox isn't enough.

At ZeroClick I led the execution layer for partner-authored integrations. Partners wrote provisioning code, the script that signs a customer up on the partner’s side and hands back working credentials, and that code ran on our servers, inside a Node vm sandbox, in the same process as our API.

The stakes were concrete. During a run the code held real tokens, could make network calls from inside our cluster, and could pause to ask a human a question. A mistake at that boundary meant a partner script reading our cloud credentials or another tenant’s keys, and for stretches of time it could have.

You may be facing the same shape of problem. A partner ships an integration, a user saves an automation, or a model generates a script, and it has to execute somewhere, which usually means your own infrastructure. If code you didn’t write runs next to code you did, the boundary between them is yours to design.

This used to be a niche concern for plugin platforms. AI engineering has made it the default: agents generate code on demand, and MCP servers execute tool calls against live systems. Every product that lets a model act ends up with an execution layer, usually before anyone has planned for it.

We started with the obvious defaults. We handed the sandbox the platform’s own fetch, piped its console into our logger, and left the status endpoint public. Each one produced a real exposure, and each fix in this article came from closing one.

What held up was treating the injection boundary as the thing under review: enumerate everything the sandbox can touch, wrap its network access, control its output channels, bound its time, and scope its credentials to the single run. The sections below walk through each losing default, why it fails, and the fix that replaced it, ending in the checklist I now run against any execution layer before it faces partners or agents.

A VM context isolates scope, not capability

Node’s vm module gives your code a fresh global namespace, and that is the whole guarantee. It controls which names the code can see. It says nothing about what the code can do. Whatever you place on that global is the attack surface, and everything reachable through those objects comes with it.

what you list in the reviewwhat the code hands overACCESS_TOKENOAUTH_CREDENTIALSCONTEXTprompt4 globalsfetchconsolesetTimeoutsetIntervalclearTimeoutclearIntervalPromiseDateMathJSONcryptoprocess (stub)moduleexports+ the 4 on the leftroughly 18 keys, not 4the part that is not a listany host-realm object you pass incarries its prototype chain with itPromise.constructor('return process')()reaches the host realm, not the stubEnumerate the sandbox object in code. Do not enumerate it from memory.The gap between the two columns is the part nobody reviews.
The four API globals are the ones anyone can name. The intrinsics beside them are the ones that carry a path back to the host realm.

It is tempting to count only the API you meant to expose. Our sandbox handed the code four things by design: a scoped access token, a context object describing the run, the provisioned credentials for the run, and a prompt function for asking a human a question mid-run. That short list is not the real list.

So what is actually on that global? Everything we added so ordinary code would run: fetch, console, timers, Promise, Date, Math, JSON, and crypto, plus a neutered process stub whose exit throws. Count the keys and you are closer to fifteen than four, and every one of them is part of the surface.

Here is why the utilities matter as much as the credentials. Any host object you pass into a vm context carries its prototype chain with it, and that chain leads back to the Function constructor in your realm. Give the sandbox a bare Promise and the code can climb from it to your process.

const vm = require('node:vm');

// A context that looks locked down: one harmless utility injected.
const context = vm.createContext({ Promise });

// Untrusted code climbs the prototype chain of the object you gave it.
// Promise.constructor is the Function constructor from YOUR realm.
vm.runInContext(
  "Promise.constructor('return process')().exit(1)",
  context,
);
// The sandboxed script just called process.exit on your host.

The injected surface is not the four names you think about. It is every object on the global, and each host object is a potential way out. That is the frame for everything below.

Sandboxed code can reach everything your server can

Integration code needs to make HTTP calls. That is the point of an integration. The trouble is in how you grant the ability. A three-line script can fetch http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token and receive a bearer token for your cloud project, with no error and no alert.

That address is the cloud metadata server. On a host with workload identity enabled, the cloud feature that gives each pod its own service account, it hands service-account credentials to any process that asks from inside the pod, authenticating by network position alone. Anything sharing the pod’s network is “inside.”

Our first version gave the sandbox the platform’s own fetch, bound straight to the host. For about five weeks, partner code could reach any internal service the pod could reach, and the metadata server along with them. We caught it in our own review and replaced raw fetch with a wrapper. Never hand the sandbox your platform’s own fetch: wrap it so it resolves DNS, checks the resolved address, and refuses private ranges and metadata hosts.

  • It enforces HTTPS. No plaintext egress from the sandbox.
  • It rejects the metadata hostnames by name, both the GCE and GKE forms.
  • It resolves DNS itself and rejects any answer in a private or link-local range: the RFC 1918 private blocks, 127.0.0.0/8, 169.254.0.0/16, 0.0.0.0/8, and the IPv6 equivalents.
  • It fails closed. A DNS error rejects the request rather than letting it through.
import dns from 'node:dns/promises';
import net from 'node:net';

const BLOCKED = [/^127\./, /^10\./, /^169\.254\./, /^192\.168\./,
  /^172\.(1[6-9]|2\d|3[01])\./, /^0\./];
const METADATA_HOSTS = new Set([
  'metadata.google.internal',
  'metadata.gke.internal',
]);

async function safeFetch(url) {
  const { protocol, hostname } = new URL(url);
  if (protocol !== 'https:') throw new Error('https only');
  if (METADATA_HOSTS.has(hostname)) throw new Error('metadata blocked');

  const ip = net.isIP(hostname)
    ? hostname
    : (await dns.lookup(hostname)).address; // fails closed on DNS error
  if (BLOCKED.some((range) => range.test(ip))) {
    throw new Error('private range');
  }
  return fetch(url); // see the gap below: this re-resolves the name
}

One gap worth naming. If you validate the resolved address and then call fetch on the original URL, the fetch resolves the name a second time, and a low-TTL record can answer with a public address for your check and a private one for the connection. To close it, pin the connection to the address you validated instead of handing the URL back to a resolver.

Anything the sandbox prints lands in your logs

A partner is debugging their integration and logs the token response they just received. The refresh token is now a plaintext string in your log aggregator, searchable by everyone with read access. Nobody attacked anything. Someone printed a variable.

We had piped the sandbox’s console straight into the platform logger so partners could debug. Console output carries whatever the code chooses to print, including the credentials it legitimately holds during a run, and those lines flowed into log aggregation and every dashboard built on it.

Our first fix was a redaction pass over the values we knew were sensitive: the access token and the two OAuth tokens. We replaced it the same day, minutes later, by disabling the sandbox console in production and keeping it only in development. You cannot enumerate what an adversary will print, so control the channel rather than the content.

Error reporting leaks the same way. The layer that relayed the sandbox’s GraphQL requests back to our API logged the full variables object whenever a mutation failed, and those variables carried session and provisioning tokens. The fix was one line: log Object.keys(variables), not the values. “Provisioning failed for fields sessionToken, tenantApiKey” debugs as well as the values do, and it survives a log breach.

The timeout fires while a human is still reading

A provisioning run pauses to ask the user for a project name. The user is reading the question. At minute five the run dies with a timeout error, and the work so far is gone.

Every run needs a wall-clock timeout, a cap on elapsed real time rather than CPU time. Ours was five minutes. The subtle part is deciding what resets it. Our runs could suspend mid-execution to ask a human a question, and a run waiting on a person is not a runaway loop.

So the answer re-arms the timer. Each time an answer to one of those questions comes back, the five-minute budget starts fresh, and the segments where code is actually running draw it down. Decide deliberately what re-arms your timeout, because the naive version kills the interactive flows you built the system for.

const TIMEOUT_MS = 5 * 60 * 1000;
let timer;

function armTimeout(reject) {
  clearTimeout(timer);
  timer = setTimeout(
    () => reject(new Error('execution timed out')),
    TIMEOUT_MS,
  );
}

// The injected prompt re-arms the clock when the answer comes back,
// so each execution segment gets a fresh budget.
const prompt = (question) =>
  askHuman(question).then((answer) => {
    armTimeout(rejectRun);
    return answer;
  });

Two caveats the tidy version hides. Time spent waiting still counts until the answer arrives, so a person who sits on one question past five minutes trips it anyway. And the rejection abandons the promise; it cannot halt code already running in the context, which is the next problem.

A shared process has no memory or CPU ceiling

One partner uploads while (true) {} in their provisioning code, and the API pod stops answering health checks for every tenant on the box until an orchestrator restarts it. The wall-clock timer did not save you, because it bounds synchronous execution and nothing else.

A same-process VM gives you no memory or CPU ceiling, so one tight loop or large allocation takes down the whole process. That is the line where you move to a worker thread, a separate process you can kill, or a microVM that brings its own kernel.

We stayed same-process because our code came from contracted partners we reviewed, not anonymous submissions, and it was authored through an internal admin dashboard rather than a self-serve signup. That is a threat-model call. Make it explicitly rather than by inheriting the default.

The execution contract

Make untrusted code conform to a shape you defined. Our integrations were a fixed set of named lifecycle functions with a defined callback shape and explicit result and error conventions. The code filled in the functions; it did not get to decide the interface.

On the capability side, nothing ambient was exposed: no require to pull in packages, no real process object, no filesystem. The only things reachable were the ones we put on the global, which loops back to the first section. Enumerate exactly what you inject and treat that list as the security review.

If the list fits on one screen, a reviewer can hold it in their head and reason about each entry. If it runs to fifteen keys and nobody wrote them down, the four you remember are not the ones that will bite you.

A secret on the browser hop is a secret an XSS can read

The provisioning flow has a browser leg. OAuth pages redirect through it and a status endpoint gets polled for progress. Put a credential on that leg and any script injected into the page, or any XSS, reads it straight out of the response.

So keep secrets off it. Pass the sandbox a scoped, short-lived token for the one job at hand, and exchange credentials server to server, never through the caller.

The sandbox got a per-run access token and the credentials that one integration needed, each scoped to a single provisioning job and expiring in minutes, never a long-lived platform credential. The browser saw status and a redeemable reference. The tenant’s own backend redeemed that reference against its API key to receive the credentials. The exchange happened server to server, always.

Your status endpoint is handing out other tenants’ secrets

Curl the status URL with the identifier of another tenant’s provisioning job, no auth header, and the JSON comes back with their freshly provisioned API keys. That was a real state of our system for a while.

The endpoint had been made public so the browser could poll it without ceremony, and its success payload still carried the provisioned credentials. The job identifier is a value the caller supplies, not a long random secret that serves as its own proof of permission, so anyone holding one could pull another tenant’s secrets.

The fix had two parts. Status stopped returning secrets. Results moved behind a one-time token tied to the caller that started the job, redeemed server-side. Any endpoint that returns results from sandboxed execution needs its own authentication, and status paths return status, never secrets.

Getting one-time right took a second try. Our first attempt minted a fresh token on every poll, which turned “one-time” into an unlimited supply. The redemption is worth stating precisely too: it reads the token from Redis and then deletes it, two round trips rather than an atomic read-and-delete, so two concurrent redemptions can both win the race. One-time means one token, used once, and if you want the guarantee under concurrency you need a single atomic operation.

The checklist

  1. List every object you inject into the sandbox, not just the API you meant to expose. That list is your attack surface. Review the list, not the sandboxed code.
  2. Wrap all network access: enforce HTTPS, reject metadata hosts by name, resolve DNS and reject private and link-local ranges, fail closed, and connect to the address you validated.
  3. Disable sandbox console output in production. Control the channel, not the content.
  4. In error reports from credential-handling code, log variable names, not values.
  5. Set a wall-clock timeout and decide deliberately what re-arms it.
  6. Know your resource-ceiling story. Same-process means none. Decide whether your threat model tolerates that.
  7. Fix the entry points, callback shape, and error conventions. Make the code fit your contract.
  8. Scope every token to the run. Exchange credentials server-side, never through the caller.
  9. Authenticate every endpoint that touches execution results. Status paths return status, never secrets.

None of these bugs needed a sophisticated attacker. Each was an ordinary engineering decision that read as reasonable in isolation and turned wrong at the boundary: hand over fetch, pipe the console, open the status endpoint. We found every one of them in-house, before a partner or an outsider did. The way to keep it that way is to review the boundary as its own artifact, before the first line of code you didn’t write ever runs.

Questions clients ask

Is Node’s vm module safe for untrusted code?

Not by itself. A vm context isolates scope, not capability: the code gets a fresh global namespace and nothing more is promised. Anything you place on that global, and anything reachable through those objects, is available to the code, and Node’s own documentation says the module is not a security mechanism. It becomes workable only when you inject a small, wrapped API surface, add a wall-clock timeout, and accept that it gives you no memory or CPU ceiling.

What should I use instead of the vm module?

It depends on your threat model. For partner code you review and contract with, a same-process VM with a wrapped fetch, no ambient globals, and a timeout can be acceptable. If you need memory or CPU limits, move to a worker thread or a separate process you can kill. For fully adversarial code, such as anonymous users or model-generated scripts run at scale, use a microVM such as Firecracker, which brings its own kernel, or a container hardened with gVisor or seccomp, layers that filter what the code can ask of the host kernel, plus network policy. A plain container shares the host kernel and is not an isolation boundary by itself. The injection-surface discipline is the same at every tier; the tiers differ in what happens when the code misbehaves.

How do I stop sandboxed code from calling my internal services?

Never hand the sandbox your platform’s own fetch. Wrap it: resolve DNS yourself, check the resolved address before you connect, and reject private ranges, link-local ranges, and cloud metadata hostnames. A hostname allowlist alone loses to DNS rebinding, where a name resolves to a public address when you check it and a private one when you connect, so connect to the address you validated rather than re-resolving the name. Enforce HTTPS too.

How do I let partners test their code safely?

Give them the exact sandbox that runs in production, pointed at test credentials. Keep developer conveniences such as console output and verbose errors enabled in the development environment only, and strip them in production. If the test environment behaves differently from production in its injected API surface, partners will ship code that breaks, and you will be tempted to loosen production to match.

Exposing an execution layer to partners or agents?

Before partner code or agent traffic touches your infrastructure, have someone who has done this walk your integration surface. My MCP server audit is $2,000, takes a week, and covers the execution boundary: what you inject, what can be reached through it, and what leaves through your logs and endpoints. The fee is credited toward any follow-on work.

Book a free intro callEmail me insteadI personally reply within one business day. · Taking new projects now.