Isolating Wasm Modules from Each Other
This page answers one task: a page or host runs several WebAssembly modules — plugins from different vendors, documents from different users, a trusted core and an untrusted extension — and they must not read or influence each other’s data. You want to know which boundaries actually separate them, from the lightest to the strongest, and which to use when.
Prerequisites
- [ ] Clarity about who wrote each module and whose data it handles.
- [ ] Knowledge of what each module imports (memories, tables, functions).
- [ ] For iframe isolation, the ability to serve content from another origin.
Levels of isolation
WebAssembly gives each instance its own linear memory, tables and globals unless they are explicitly shared through imports. That is the first and cheapest boundary: two instances that share nothing cannot read each other’s memory. But instances in the same JavaScript context can be reached by any code in that context, and they share whatever the host passes to both — callbacks, objects, storage APIs. Stronger boundaries come from the platform: a dedicated worker gives a separate JavaScript context and event loop; a cross-origin iframe gives a separate origin (storage, cookies, permissions) and, with site isolation and proper headers, a separate agent cluster and often a separate process. Each level costs more to set up and to communicate across.
Step 1 — never share memory or tables unintentionally
Two instances are isolated at the Wasm level only if they do not import the same WebAssembly.Memory, WebAssembly.Table or mutable WebAssembly.Global.
Audit the import objects you build: helper code that “reuses” a memory for efficiency, or a shared table for callbacks, connects modules that should be
separate. Give each untrusted module its own memory, sized for its needs, and its own table.
function instantiateIsolated(module) {
const memory = new WebAssembly.Memory({ initial: 16, maximum: 256 }); // fresh per instance
return WebAssembly.instantiate(module, { env: { memory, log: makeScopedLogger(module) } });
}
Step 2 — scope the imports
Imports are capabilities. If two modules receive the same storage_put function, they can read and overwrite each other’s data through it. Create imports per
module, scoped to that module’s identity: a key-value function that prefixes keys with the module’s ID, a logger that tags messages, an HTTP function with a
per-module allow-list. Never hand a module a host object that exposes other modules (a registry, a global state object).
Step 3 — move untrusted modules into workers
Instances in the main page share the page’s JavaScript context; a module whose glue code (JavaScript) is untrusted can reach the DOM, cookies and other instances. Run untrusted modules — and their glue — in dedicated workers, one per trust domain. Workers have no DOM access and can only communicate through messages you define. Terminating a worker discards everything it held, and a crash or infinite loop affects only that worker.
Step 4 — use cross-origin iframes for third-party code
When modules come from third parties and must not access your origin’s storage, cookies or permissions at all, host them in an iframe on a separate origin
(for example plugins.example-usercontent.com) with the sandbox attribute, and communicate through postMessage with origin checks. The iframe’s code —
including its Wasm modules — has its own storage and cannot read yours. With site isolation in modern browsers, cross-site iframes typically run in separate
processes, adding OS-level protection against Spectre-style attacks. See
running Wasm in a sandboxed iframe.
Step 5 — on servers, separate per tenant
Server hosts embedding Wasmtime or similar should create a separate store and instance per tenant or per request, never share memories between tenants, and scope host functions per tenant. For the strongest separation between untrusted tenants, add process-level isolation (separate worker processes per tenant group) around the Wasm sandbox, so a runtime bug cannot cross tenants.
Spectre and timing
Separate instances in the same process share CPU caches and can, in principle, leak data through speculative-execution side channels, especially if shared
memory and high-resolution timers are available. Browsers mitigate this with site isolation and by requiring cross-origin isolation for SharedArrayBuffer.
For modules handling secrets of different parties, prefer process-separated boundaries (cross-origin iframes, separate server processes) over same-process
instances.
Choosing the boundary
Use separate instances for modules you trust that merely should not interfere (two documents opened by the same user). Use workers for modules you trust less or that might misbehave (third-party code you vetted, CPU-heavy plugins). Use cross-origin iframes for third-party code that must not touch your origin. Use process isolation on servers for multi-tenant untrusted code. The cost of messaging grows with each step, so design APIs to exchange coarse messages.
Designing message APIs across boundaries
Every boundary stronger than “separate instances” means communicating through messages, and the message API becomes the security boundary. Design it like a
network protocol: a small set of request types with explicit schemas, validated on receipt; no passing of functions or object references that carry authority;
responses that contain data, not capabilities. On the receiving side, check the sender — event.origin for iframe messages, the specific MessagePort for
workers — before acting, and never evaluate message contents as code. Keep messages coarse: “render this document” rather than hundreds of fine-grained calls,
both for performance and because a small API is easier to reason about. Transfer large binary data (ArrayBuffer in the transfer list) instead of copying,
and remember that transferring hands the buffer over entirely — the sender can no longer read it, which is itself a useful isolation property.
Verifying isolation in tests
Isolation claims should be tested, not assumed. Write a hostile test module for each boundary that tries to cross it: reading a shared memory it should not
have, calling storage functions with another module’s keys, posting messages that impersonate the host, reaching parent or top from an iframe, or
exhausting CPU to starve other modules. Assert that each attempt fails or is contained. Run these tests whenever the hosting code changes; isolation regressions
usually come from convenience refactors that start sharing something.
Resource isolation
Isolation is also about resources. A module that allocates without bound or loops forever can degrade others sharing a thread or process even if it cannot read their data. Give each module memory maximums, run CPU-heavy or untrusted modules in workers that can be terminated, and on servers use fuel or epoch limits per instance.
Expected output
Each plugin gets its own memory, table and per-plugin imports; vetted plugins run in dedicated workers; third-party plugins run in a sandboxed iframe on a
separate origin with postMessage APIs; the server host gives each tenant its own store with scoped host functions; and an audit confirms no memory, table or
storage function is shared between trust domains.
Gotchas
- Reusing one memory for efficiency. Modules can read each other. Give each its own.
- Sharing host functions across modules. Data leaks through the host. Scope imports.
- Running untrusted glue in the main page. It gets DOM and cookie access. Use workers or iframes.
- Same-origin iframes for third parties. No storage separation. Use another origin.
- Ignoring side channels for mutually distrustful secrets. Prefer process separation.
- Untested isolation. Refactors quietly start sharing things. Keep hostile cross-boundary tests.
Performance note
Messaging cost grows with isolation: a call into a same-page instance costs nanoseconds, a worker round trip about 0.1–0.3 ms, and a cross-origin iframe
postMessage round trip slightly more.
Frequently Asked Questions
Are two instances of the same module isolated? Yes, if they share no memory, tables, mutable globals or stateful imports.
Can a worker read the main page’s Wasm memory? Only if you send it the memory (shared) or copies; otherwise no.
Do sandboxed iframes allow Wasm? Yes; the sandbox restricts capabilities like scripts’ origin access, and scripts must be allowed for Wasm to run.
Is the Component Model relevant? Yes — components never share memory by design, which suits composing untrusted parts.
How should modules in workers or iframes communicate? Through a small, schema-validated message API that checks the sender and carries data, not capabilities.
How do I verify modules are really isolated? Run hostile test modules that try to cross each boundary — shared memory, other modules’ keys, impersonated messages — and assert they fail.
Can one module slow down others without reading their data? Yes, by exhausting CPU or memory; set memory maximums and run risky modules in terminable workers or with fuel limits.
Does transferring an ArrayBuffer help isolation? Yes — after transfer the sender can no longer read the buffer, so data is handed over rather than shared.
Related
- Running Wasm in a sandboxed iframe — origin isolation.
- Restricting what a module can import — capability control.
- Spectre and cross-origin isolation for Wasm — side channels.
- Building a plugin system in the browser — applying this.
← Back to Browser Sandbox & Security Boundaries