Running Wasm in a Sandboxed iframe

This page answers one task: run a WebAssembly module you do not trust — together with whatever JavaScript glue it came with — so that even if the glue misbehaves, it cannot read the page’s data, cookies or storage.

Prerequisites

  • [ ] A module and loader that work on their own in a page.
  • [ ] Control over the host page and a way to serve the sandbox document, ideally from a separate origin.
  • [ ] A clear, small set of operations the page needs from the module.

Why a module’s glue needs containing too

The WebAssembly sandbox is strong at the instruction level: a module can only do what its imports allow, as described in restricting what a module can import. But modules rarely arrive alone. A third-party library usually ships JavaScript glue — an Emscripten runtime, wasm-bindgen output, a vendor wrapper — and that glue runs as ordinary JavaScript in your page, with full access to the DOM, cookies, storage and network. Reviewing the module’s imports does nothing to restrict the glue.

A sandboxed iframe moves the glue and the module into a separate browsing context with an opaque origin. An opaque origin matches no real origin, so the frame cannot read the parent’s DOM, cookies, localStorage or IndexedDB, cannot make credentialed requests as the user, and — with the right sandbox flags — cannot navigate the top page or open popups. The only channel left is postMessage, which the page controls. That turns “trust the vendor’s JavaScript” into “trust the messages it sends back”, which you can validate.

The layers between an untrusted module and the page The untrusted module and its glue run inside a sandboxed iframe with an opaque origin and its own CSP. The only connection to the parent page is a postMessage channel whose messages the page validates before acting on them. host page (your origin) DOM, cookies, storage, user session postMessage channel validated messages only, transferable buffers sandboxed iframe (opaque origin) allow-scripts only; no same-origin, no top navigation frame CSP script-src self wasm-unsafe-eval; connect-src none untrusted glue + Wasm module can compute and message, nothing more

Step 1 — create the sandboxed frame

<iframe id="wasm-sandbox"
        src="https://sandbox.example-usercontent.com/runner.html"
        sandbox="allow-scripts"
        style="display:none"></iframe>

sandbox="allow-scripts" and nothing else is the key line. Scripts may run — the module needs them — but without allow-same-origin the frame’s origin is opaque, without allow-top-navigation it cannot redirect the page, and without allow-popups or allow-forms it cannot open windows or submit forms. Never combine allow-scripts with allow-same-origin for a frame served from your own origin: a script inside could simply remove its own sandbox attribute.

Serving the runner from a separate origin — a dedicated domain used only for untrusted content — adds a further layer, so that even a browser bug in sandbox enforcement leaves the frame in a different site from your application.

Step 2 — lock down the runner with its own CSP

The runner document should be minimal and should forbid everything the module does not need. In particular, deny network access, so untrusted code cannot send data anywhere even if it obtains some:

Content-Security-Policy:
  default-src 'none';
  script-src 'self' 'wasm-unsafe-eval';
  worker-src 'self';
  connect-src 'none';
  img-src 'none'; style-src 'none';
  form-action 'none'; base-uri 'none'

With connect-src 'none' the frame cannot fetch, so the module must be delivered by the parent as bytes rather than fetched by URL. That is a feature: the parent decides exactly which module runs, and can verify its integrity first as in verifying Wasm integrity before instantiation. 'wasm-unsafe-eval' allows compilation, as explained in writing a Content Security Policy for Wasm.

Step 3 — define a small message protocol

Everything crosses postMessage, so define the protocol explicitly: a request type, an id for matching responses, and typed payloads. In the runner:

// runner.js — inside the sandbox
let instance;
self.addEventListener("message", async (event) => {
  if (event.source !== parent) return;                         // only the embedding page
  const { id, type, payload } = event.data ?? {};
  try {
    if (type === "load") {
      const { instance: inst } = await WebAssembly.instantiate(payload.bytes, makeImports());
      instance = inst;
      parent.postMessage({ id, ok: true }, "*");
    } else if (type === "process") {
      const out = runFilter(instance, payload.pixels, payload.width, payload.height);
      parent.postMessage({ id, ok: true, result: out }, "*", [out.buffer]);
    }
  } catch (err) {
    parent.postMessage({ id, ok: false, error: String(err).slice(0, 500) }, "*");
  }
});

The target origin is "*" because an opaque-origin frame has no origin to name; the page’s side compensates by checking event.source. Transfer large buffers instead of copying them by listing them as transferables.

Step 4 — validate every message on the page’s side

Treat responses from the frame as untrusted input, exactly like data from a network:

const frame = document.getElementById("wasm-sandbox");
const pending = new Map();
let nextId = 1;

window.addEventListener("message", (event) => {
  if (event.source !== frame.contentWindow) return;             // ignore everything else
  const msg = event.data;
  if (typeof msg !== "object" || !pending.has(msg?.id)) return;
  const { resolve, reject, expect } = pending.get(msg.id);
  pending.delete(msg.id);
  if (!msg.ok) return reject(new Error(String(msg.error)));
  if (expect === "pixels" && !(msg.result instanceof Uint8ClampedArray)) return reject(new Error("bad result type"));
  resolve(msg.result);
});

function call(type, payload, expect, transfer = []) {
  const id = nextId++;
  return new Promise((resolve, reject) => {
    pending.set(id, { resolve, reject, expect });
    frame.contentWindow.postMessage({ id, type, payload }, "*", transfer);
    setTimeout(() => pending.has(id) && (pending.delete(id), reject(new Error("sandbox timeout"))), 5000);
  });
}

Check the source, check the shape, check the types and sizes, and never insert strings from the frame into the DOM as HTML. A timeout guards against a frame that never answers; reloading the frame resets it completely.

A processing request through the sandbox The page sends a process request with the image buffer transferred to the sandboxed frame. The runner calls the module, which can only compute. The result buffer is transferred back, and the page validates the response's source, id and type before using it. host page sandboxed iframe Wasm module postMessage({id:7, type:process}) + transfer filter(ptr, w, h) result in linear memory postMessage({id:7, ok, result}) + transfer check source

Step 5 — combine with a worker for heavy work

A sandboxed frame runs on the page’s main thread in many browsers when it is same-site, and long computations there still freeze the page. Start a worker inside the frame and run the module there; the frame then only relays messages. The worker inherits the frame’s opaque origin and CSP, so the containment is unchanged:

// runner.js
const worker = new Worker("runner-worker.js", { type: "module" });   // allowed by worker-src 'self'

Deciding what crosses the boundary

The protocol is the security boundary, so its design deserves the same care as an API between services. Keep message types few and specific: load, process, reset — not a general call(method, args) that lets the frame ask the page to do arbitrary things. Every message the page accepts from the frame should map to one well-defined action whose worst case you have thought through. A result message that the page renders into a canvas is low risk; a message that triggers a navigation, a network request or a write to storage on the page’s behalf should be validated as strictly as user input to a server endpoint.

Data should flow mostly in one direction. The page sends the frame inputs and the module bytes; the frame returns results. Avoid giving the frame callbacks into the page, such as “fetch this URL for me”, unless the page restricts them to an allow-list it controls. Such callbacks are where sandboxes quietly turn back into full access.

Finally, decide how failures are handled. A frame that crashes, hangs or sends malformed messages should be torn down and replaced with a fresh one — by resetting its src — rather than repaired. Fresh frames start from a known state, and because the module is delivered by the page, reloading costs only the compile time of a module that is probably already in the browser’s code cache.

Expected output

From the page, using the sandboxed module looks like calling an async function:

await call("load", { bytes: verifiedModuleBytes }, "ack", [verifiedModuleBytes.buffer]);
const output = await call("process", { pixels, width, height }, "pixels", [pixels.buffer]);
ctx.putImageData(new ImageData(output, width, height), 0, 0);

From the frame’s console, attempts to reach the outside fail: document.cookie throws, localStorage throws, fetch is refused by CSP, and parent.document throws a cross-origin SecurityError.

Gotchas

  • allow-same-origin added to make storage work. That gives the frame your origin and defeats the sandbox. If the module needs storage, give it a message-based storage API the page implements.
  • Trusting event.origin. It is "null" for opaque origins and cannot distinguish frames. Check event.source instead.
  • Inserting results as HTML. Strings from the frame are untrusted; use textContent, never innerHTML.
  • Copying large buffers. Without transferables every request copies the data twice. Transfer ArrayBuffers in both directions.

Performance note

The sandbox adds a message round trip per call. With transferred buffers, a round trip for a 1920×1080 RGBA frame cost about 0.3 ms, against 6 ms for the filter itself; copying the buffer instead of transferring it raised the overhead to 4.1 ms. The frame and its worker also cost a few megabytes of memory and about 30 ms to start, so create it once and reuse it.

Overhead of the sandbox boundary per frame Time added by passing a full HD frame through a sandboxed iframe to a worker and back, with transferred buffers and with copied buffers, compared with the filter's own run time. ms per full HD frame filter itself 6 ms round trip, buffers transferred 0.3 ms round trip, buffers copied 4.1 ms

Frequently Asked Questions

Is an iframe needed if I already restrict imports? For a bare module with imports you wrote, import restriction is enough. The iframe matters when untrusted JavaScript comes along with the module — glue, a vendor wrapper, a scripting runtime such as Pyodide.

Can the frame be visible? Yes — a sandboxed frame can render its own UI. Visible frames should still not receive allow-same-origin, and their size and position are controlled by the page.

Does this work in Safari and Firefox? Sandboxed iframes and opaque origins are supported everywhere. Check that 'wasm-unsafe-eval' is supported in the browsers you target, or the module will not compile in the frame.

What about cross-origin isolation? A frame embedded in an isolated page must itself be served with COEP-compatible headers. Threads inside the sandbox are possible but add configuration; see Spectre and cross-origin isolation for Wasm.

← Back to Browser Sandbox & Security Boundaries