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.
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.
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-originadded 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. Checkevent.sourceinstead. - Inserting results as HTML. Strings from the frame are untrusted; use
textContent, neverinnerHTML. - 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.
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.
Related
- Building a plugin system in the browser — a plugin host built on this pattern.
- Running Python in the browser with Pyodide — a runtime worth sandboxing when it runs user code.
- Transferring ArrayBuffers to workers without copying — the transfer mechanics used above.
- Security implications of Wasm in enterprise apps — when the extra wall is required.
← Back to Browser Sandbox & Security Boundaries