Browser Sandbox & Security Boundaries
A WebAssembly module is one of the most tightly confined things a browser will run: it starts with zero ambient authority — no DOM, no network, no filesystem, no clock beyond what JavaScript hands it — and every byte it can touch lives inside a single bounds-checked buffer. This guide explains the machinery that enforces that confinement, where the trust boundary actually sits, and the handful of HTTP headers and policies you must get right so the sandbox stays intact when you start sharing memory across threads.
Prerequisites
- [ ] A modern engine with the relevant features: Chrome/Edge 119+, Firefox 120+, or Safari 17+
- [ ]
wabt≥ 1.0.34 forwasm-validateandwasm-objdump(brew install wabt/apt install wabt) - [ ] A local server you can set response headers on (the
crossOriginIsolatedchecks below need them) - [ ] Familiarity with the stack-based execution model and how
linear memoryis addressed - [ ] DevTools open, Console tab — every verification step below runs from there
The trust boundary: capabilities granted through the import object
The single idea that makes WebAssembly safe is capability-based execution. A compiled module cannot
name a syscall, dereference a host pointer, or reach document directly. It can only call functions
that JavaScript explicitly placed in the import object at instantiation time. If you never import a
function that calls fetch, the module physically cannot make a network request — the capability does
not exist in its world. This is the inverse of the ambient-authority model native processes use, where
code inherits every privilege of the user running it.
Everything the module is allowed to affect in the outside world passes through that doorway. Nothing crosses it implicitly. The deeper interop mechanics of how data — not just authority — moves through this channel are covered in JS/Wasm interop & memory management, but for security the rule is simpler: the import object is the entire attack surface you grant.
Bounds-checked linear memory
A module’s heap is a single contiguous ArrayBuffer called linear memory, sized in 64 KiB page
units. Every load and store the module executes carries an i32 offset into that buffer, and the
engine bounds-checks every one of them. An access past memory.byteLength does not read adjacent host
memory or crash the tab — it deterministically raises a trap, which surfaces in JavaScript as a
WebAssembly.RuntimeError. A pointer in Wasm is just an index into this buffer; it can never name an
address the engine did not allocate, so a buffer overflow inside the module stays inside the module.
(module
(memory (export "memory") 1) ;; exactly one 64 KiB page
(func (export "peek") (param $off i32) (result i32)
(i32.load (local.get $off))) ;; off >= 65536 traps, never escapes
)
You should still cap growth explicitly. An unbounded WebAssembly.Memory can be grown by a misbehaving
or malicious module until the tab is OOM-killed, so set a hard maximum at construction:
const memory = new WebAssembly.Memory({
initial: 2, // 128 KiB to start
maximum: 64, // 4 MiB hard ceiling — memory.grow past this fails, returns -1
});
This is the same isolation that makes pointer hand-offs across the boundary safe: the value stack and
linear memory heap are addressed separately, so an overflow in one cannot reach the other, and every
offset you receive from JavaScript should still be treated as untrusted and re-validated.
There is a subtlety worth internalizing: bounds checking protects the host, not your application logic.
A module cannot read past its own memory, but nothing stops it from reading the wrong part of its own
memory if your glue code hands it a bad offset. Security at the boundary is therefore a shared
responsibility — the engine guarantees the module cannot escape the buffer, and you guarantee the offsets
and lengths flowing across the boundary actually describe the data you intend. Most real-world Wasm
“security” bugs are this second kind: a confused-deputy mistake in the JavaScript glue, not a sandbox
escape. Validate every (ptr, len) pair against the current memory.byteLength before constructing a
view, and treat any length you did not compute yourself as hostile.
Control-flow integrity
Native exploitation usually pivots on corrupting a return address or jumping into the middle of an
instruction. WebAssembly makes both impossible structurally. The call stack and return addresses live
in protected engine state the module cannot address — they are not in linear memory, so no overflow
can overwrite them. Branches can only target labels that the validator proved exist, and indirect calls
go through a table of typed function references: an i32.const index into that table, with the engine
checking the callee’s type signature against the call site at runtime. A type mismatch traps. There are
no computed jumps into arbitrary code, no executable data pages, and no way to forge a function pointer
out of an integer. The structural validation that guarantees this happens before any code runs, as
detailed in the Wasm binary format deep dive.
The practical upshot for an engineer is that a whole class of bugs that are catastrophic in C — stack
smashing, return-oriented programming, jumping to shellcode planted in a data buffer — are not just
mitigated but unrepresentable in the WebAssembly execution model. The corresponding native bug usually
turns into a trap: a deterministic, recoverable error you can catch in JavaScript, rather than silent
memory corruption. That changes how you triage. A crash report from a Wasm module is almost always a
logic error or an out-of-bounds access that the engine caught, not a foothold for an attacker.
How the Wasm sandbox compares to a native process
It helps to place this model against the isolation a native process gets from the operating system. A
native process is confined by the MMU and the kernel’s syscall boundary: it can do anything its user
account permits, and the OS only stops it at page-fault and privilege-ring boundaries. A WebAssembly
instance is confined far more tightly. It has no syscalls at all — only the functions you imported — and
its entire addressable world is one ArrayBuffer, not a sparse virtual address space full of mapped
libraries, the stack, and the heap. There is no ptrace, no /proc, no shared mutable global state with
other instances unless you deliberately wire it up. Two modules instantiated on the same page are as
isolated from each other as two browser tabs, because each owns a private linear memory and neither can
name the other’s. This is why running an untrusted plugin as a Wasm module is a fundamentally stronger
position than running it as a native shared library loaded into your process: the sandbox is the default,
and every capability is opt-in rather than opt-out.
Cross-origin isolation: COOP, COEP, and the timer problem
Single-threaded WebAssembly needs none of this. The moment you want threads, you need a SharedArrayBuffer
so multiple Web Workers can see the same linear memory — and a SharedArrayBuffer re-enables the
high-resolution, shared-state timing primitives that Spectre-class side-channel attacks depend on.
Browsers therefore gate it behind cross-origin isolation: your top-level document must be served
with two response headers, and only then is SharedArrayBuffer constructable and crossOriginIsolated
true.
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
COOP: same-origin severs the window.opener relationship so cross-origin documents cannot share a
browsing-context group with yours. COEP: require-corp forces every subresource you load to explicitly
opt in (via Cross-Origin-Resource-Policy or CORS), so no unconsented cross-origin data lands in your
now-isolated process. Together they let the browser put your page in its own process where a leaked
high-resolution timer reveals nothing it should not. The full threading model that depends on this lives
under SharedArrayBuffer, Atomics & threading,
and the exact local-server header setup is in
configuring COOP/COEP headers.
Even with isolation enabled, treat shared memory as a privilege you scope tightly. Browsers also clamp
performance.now() resolution and add jitter outside isolated contexts precisely so that timing a memory
read cannot be turned into a cache-probe oracle.
Spectre and the timer side channel
Spectre-class attacks do not break the sandbox’s correctness — bounds checks still hold — they break its
confidentiality by inferring secret bytes from how long an operation takes. Speculative execution can
transiently access data past a bounds check before the check resolves, leaving a footprint in the CPU
cache that an attacker reads back by timing subsequent accesses. The exploit therefore needs two things: a
high-resolution clock and a way to share state with the victim. Browsers attack both. Outside a
cross-origin-isolated context, performance.now() is coarsened (to ~100 µs in several engines) and
deliberately jittered, and the precise multi-threaded timer you could build from a SharedArrayBuffer
counter is simply unavailable because SharedArrayBuffer is gated off. This is the real reason the COOP
and COEP dance exists: enabling shared memory hands back the timing precision an attacker needs, so the
browser will only do it once your page has provably isolated itself into its own process where the secrets
worth stealing are your own. The defensive posture for application code is straightforward — do not treat
cross-origin isolation as a feature flag to flip casually, and never load secret-bearing cross-origin
content into an isolated context where a co-resident module could time it.
CSP for WebAssembly: wasm-unsafe-eval
Content Security Policy treats compiling a .wasm module as a form of code generation. Under a strict
policy, WebAssembly.compile and instantiate are blocked unless you add the 'wasm-unsafe-eval'
source to script-src. This keyword is deliberately narrower than 'unsafe-eval': it permits
WebAssembly compilation without re-enabling JavaScript eval() and new Function(), so you keep
the strong JS protection while allowing Wasm.
Content-Security-Policy:
default-src 'self';
script-src 'self' 'wasm-unsafe-eval';
object-src 'none';
Pair this with Subresource Integrity on the loader script and a hash check on the binary itself (shown below) so only the exact module you shipped can run.
Workflow: build a least-privilege loader
Follow these steps to instantiate a module with the smallest possible capability set and verify the sandbox held.
-
Decide the capability budget. List every host function the module genuinely needs. If it only logs, it gets
logand nothing else — nofetch, no DOM accessor, noevalwrapper. -
Compile with hardening flags so the binary itself is minimal and validated:
# Rust wasm-pack build --target web --release # C/C++ emcc main.c -O3 -s ASSERTIONS=0 -s EXPORTED_FUNCTIONS="['_process']" -o out.js -
Validate structure before shipping and strip metadata that widens the attack surface:
wasm-validate out.wasm wasm-opt out.wasm -Oz --strip-debug --strip-producers -o out.opt.wasm -
Verify the binary hash, then instantiate from the verified bytes with a restrictive import object (full code in the next section).
-
Audit the imports the module actually declares so it cannot demand a capability you did not vet:
const mod = await WebAssembly.compileStreaming(fetch("/out.opt.wasm")); WebAssembly.Module.imports(mod).forEach(i => console.log(`${i.module}.${i.name} : ${i.kind}`)); // Reject if anything outside your allowlist appears
A restrictive import object
This loader grants exactly one capability — bounded logging — verifies the binary’s SHA-256 before
instantiating, and exposes nothing else. Note that instantiateStreaming consumes the response body, so
to hash the bytes you fetch them once into an ArrayBuffer and instantiate from that buffer.
async function loadSandboxed(url, expectedHashB64) {
const res = await fetch(url, { credentials: "same-origin" });
if (!res.ok || res.headers.get("content-type") !== "application/wasm") {
throw new Error("bad response or wrong MIME type");
}
const bytes = await res.arrayBuffer();
// Integrity gate: refuse anything but the exact module we shipped
const digest = await crypto.subtle.digest("SHA-256", bytes);
const got = btoa(String.fromCharCode(...new Uint8Array(digest)));
if (got !== expectedHashB64) throw new Error("SRI mismatch — refusing to run");
// The entire granted capability set — one function, length-clamped
const importObject = {
env: {
log(ptr, len) {
const mem = new Uint8Array(instance.exports.memory.buffer, ptr, Math.min(len, 4096));
console.log("[wasm]", new TextDecoder().decode(mem));
},
},
};
const { instance } = await WebAssembly.instantiate(bytes, importObject);
return instance;
}
There is no fetch, no document, no eval reachable from inside the module — and because the log
length is clamped, a hostile module cannot coax the host into reading 4 GiB out of linear memory.
Gotchas & failure modes
-
crossOriginIsolatedisfalseeven though headers are “set.” A single subresource without aCross-Origin-Resource-Policyheader (a font, an image from a CDN) fails theCOEP: require-corpcheck and silently disables isolation for the whole page.SharedArrayBufferis thenundefined. -
CompileError: WebAssembly.instantiate(): Wasm code generation disallowed by embedder. Your CSP lacks'wasm-unsafe-eval'inscript-src. Adding'unsafe-eval'also works but needlessly re-enables JavaScripteval— use the narrow keyword. -
A
RuntimeErroryou never see. Wasm traps surface as rejected promises or thrownRuntimeErrors. If your call site swallows them, an out-of-bounds access looks like a “nothing happened” bug. Wrap exported calls and report traps to your error boundary. -
Trusting a length field from JavaScript. Bounds checking protects the module’s memory; it does not stop your own glue from reading the wrong region. Always clamp any
(ptr, len)you receive before building a view — never assume the caller is honest.
Verification
Confirm the boundary holds with three checks straight from the Console.
// 1. Cross-origin isolation is genuinely on
console.log(crossOriginIsolated, typeof SharedArrayBuffer);
// expect: true "function" (false / "undefined" means your COOP/COEP failed)
// 2. An out-of-bounds load deterministically traps, not corrupts
try {
instance.exports.peek(1 << 30); // far past one page
} catch (e) {
console.log(e instanceof WebAssembly.RuntimeError, e.message);
// expect: true "memory access out of bounds"
}
// 3. The module imports only what you granted
const mod = await WebAssembly.compileStreaming(fetch("/out.opt.wasm"));
console.log(WebAssembly.Module.imports(mod));
// expect: exactly [{ module: "env", name: "log", kind: "function" }]
wasm-objdump -x out.opt.wasm from the CLI gives the same import list and confirms the memory section’s
declared maximum, so you can gate CI on both.
In this guide
- Is WebAssembly faster than JavaScript for DOM manipulation? — why the sandbox boundary makes JS the faster choice for DOM work, with benchmarks.
- Security implications of Wasm in enterprise apps —
threat modeling,
.wasmsupply chain, CSP, SRI, and sandboxing third-party modules.
Frequently Asked Questions
Can a WebAssembly module access the DOM or network on its own?
No. A module has no ambient authority. It can only call functions you place in the import object at
instantiation. If you do not import a function that touches document or fetch, the module has no way
to reach them — the capability simply does not exist inside the sandbox.
What happens on an out-of-bounds memory access?
The engine raises a trap, surfaced in JavaScript as a WebAssembly.RuntimeError with the message
“memory access out of bounds.” It never reads or writes host memory outside the module’s linear memory
buffer, so a logic bug stays contained instead of becoming a memory-safety exploit.
Why do I need COOP and COEP headers?
They enable cross-origin isolation, which is the precondition for SharedArrayBuffer. Shared memory
re-introduces the high-resolution timing signals Spectre attacks exploit, so the browser only allows it
once your document has provably isolated itself from cross-origin content with these two headers.
Is 'wasm-unsafe-eval' dangerous like 'unsafe-eval'?
No. 'wasm-unsafe-eval' permits only WebAssembly compilation and leaves JavaScript eval() and
new Function() blocked. It is the minimal CSP relaxation needed to run Wasm under a strict policy and
is strictly safer than 'unsafe-eval'.
Does the sandbox protect me from a malicious third-party .wasm?
It protects the host: the module cannot escape memory or call ungranted capabilities. It does not
protect you from a module that abuses the capabilities you did grant. Vet the supply chain, pin a hash,
and scope imports — see the enterprise security guide above.
Related
- Stack vs heap execution model — how
linear memoryand the protected value stack are addressed. - Wasm binary format deep dive — the structural validation that runs before any code executes.
- SharedArrayBuffer, Atomics & threading — the threading model that cross-origin isolation unlocks.
- Configuring COOP/COEP headers — getting isolation working on your local server.