Security implications of Wasm in enterprise apps
This page walks through the concrete task of hardening a WebAssembly deployment for an enterprise web app:
building a threat model, securing the .wasm supply chain, locking down which capabilities each module
receives, and isolating third-party modules so a compromised dependency cannot pivot into the rest of the
page.
The engine-level sandbox already does a lot for you. A module starts with zero ambient authority and every memory access is bounds-checked — the full mechanics are in browser sandbox & security boundaries. What the sandbox does not do is protect you from the capabilities you choose to grant, or from shipping a tampered binary. That gap is the enterprise security problem.
Prerequisites
- [ ]
wabt≥ 1.0.34 (wasm-validate,wasm-objdump,wasm2wat) andbinaryen(wasm-opt) - [ ] A CI runner (GitHub Actions, GitLab CI) you can add a blocking security gate to
- [ ] Server control over response headers for CSP, COOP/COEP, and
application/wasmMIME type - [ ] An inventory of every
.wasmartifact and its provenance (first-party build vs npm/crate dependency) - [ ] Node 20+ for the build-time hashing and allowlist scripts below
Procedure
1. Build the threat model
Enumerate what an attacker gains by compromising a module, given the sandbox boundary. The realistic threats are not memory-safety escapes — those are blocked — but abuse of granted capabilities and supply-chain tampering:
| Threat | Vector | Sandbox stops it? |
|---|---|---|
Exfiltration via a granted fetch import |
You imported a network function | No — you granted it |
| Tampered binary shipped from a CDN | Compromised CDN / MITM | No — needs SRI / hash pin |
| Resource exhaustion | Unbounded memory.grow |
Partly — needs explicit maximum |
| Memory corruption of the host | Buffer overflow in module | Yes — bounds-checked linear memory |
| Side-channel timing leak | High-res timer in shared memory | Only outside cross-origin isolation |
The model’s output is a per-module capability budget: the minimal set of imports each module is allowed to receive.
2. Secure the .wasm supply chain
Treat every third-party .wasm (including ones pulled transitively through an npm package) as untrusted
input. Pin a content hash at build time and refuse to instantiate anything that does not match.
# Record the canonical hash of the artifact you reviewed
shasum -a 384 dist/module.wasm | awk '{print "sha384-"$1}' | \
openssl base64 -A # produces the value for an SRI attribute
Optimize and strip the binary so the deployed artifact is minimal and reproducible, then validate it:
wasm-opt --strip-debug --strip-producers -O2 dist/module.wasm -o dist/secure.wasm
wasm-validate dist/secure.wasm
3. Vet imports against an allowlist
Before instantiation, read the module’s declared imports and reject anything outside the budget from step 1.
A module that suddenly demands a fetch import it never needed is a red flag.
const mod = await WebAssembly.compileStreaming(fetch("/secure.wasm"));
const allow = new Set(["env.log", "env.now"]); // the entire budget
for (const imp of WebAssembly.Module.imports(mod)) {
const key = `${imp.module}.${imp.name}`;
if (!allow.has(key)) throw new Error(`unvetted import: ${key}`);
}
4. Instantiate with a least-privilege import object
Grant only the audited functions, cap linear memory, and keep memory non-shared until a thread-safety
review is done.
const memory = new WebAssembly.Memory({ initial: 1, maximum: 16, shared: false }); // 1 MiB ceiling
const { instance } = await WebAssembly.instantiateStreaming(fetch("/secure.wasm"), {
env: {
memory,
log: (ptr, len) =>
console.log(new TextDecoder().decode(new Uint8Array(memory.buffer, ptr, Math.min(len, 4096)))),
abort: () => { throw new Error("wasm abort"); },
},
});
5. Sandbox third-party modules in a Worker
A .wasm you do not fully trust should run in a dedicated Web Worker with its own isolated import object,
so even the capabilities you grant it are scoped to that worker and cannot see the main thread’s DOM or
state. The worker becomes a second containment ring around the engine’s own sandbox; the main thread
communicates only through a narrow postMessage protocol you control.
6. Deploy CSP and SRI headers
Allow Wasm compilation with the narrow keyword — never re-enable JavaScript eval — and force every
subresource to opt in:
Content-Security-Policy: default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; object-src 'none';
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp
Add the SRI hash from step 2 to the <script type="module"> loader so a tampered bundle is rejected by the
browser before it runs.
7. Gate it all in CI
Make the audit a blocking pipeline step so a regression cannot ship.
# .github/workflows/wasm-security.yml
name: Wasm Security Audit
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: sudo apt-get install -y binaryen wabt
- name: Optimize & strip
run: wasm-opt --strip-debug --strip-producers -O2 ./dist/*.wasm -o ./dist/secure.wasm
- name: Validate structure
run: wasm-validate ./dist/secure.wasm
- name: Inspect memory ceiling
run: wasm2wat ./dist/secure.wasm | grep "(memory"
- name: Enforce import allowlist
run: npm run test:wasm-imports
Expected output / verification
A correctly hardened deployment passes these checks. From the Console:
// imports are exactly the budgeted set, nothing more
console.log(WebAssembly.Module.imports(mod));
// [{ module: "env", name: "memory", kind: "memory" },
// { module: "env", name: "log", kind: "function" }]
// the memory ceiling is enforced — growth past 16 pages fails
console.log(instance.exports.memory.grow ? memory.grow(64) : "n/a"); // -1, not a throw
From the CLI, wasm-objdump -x dist/secure.wasm should show no debug section, the declared memory
maximum, and an import list matching your allowlist. A failing wasm-validate must block the pipeline.
Gotchas
-
unsafe-evalsmuggled in alongside Wasm. Teams sometimes add'unsafe-eval'to make Wasm compile, which silently re-enables JavaScripteval()andnew Function()across the app. Use'wasm-unsafe-eval'— it permits only WebAssembly. -
COEP: require-corpbreaks third-party assets. Once you enable cross-origin isolation, every CDN font, image, or analytics script without aCross-Origin-Resource-Policyheader fails to load andcrossOriginIsolatedflips tofalse. Audit every subresource before flipping these headers in prod. -
SRI on the loader does not cover the
.wasm. A<script integrity>hash protects the JS glue, not the binary it fetches. You must hash-check the.wasmbytes yourself (step 3) or the binary remains a tamper point. -
Trusting a length passed from the module. Bounds checking protects the module’s own memory; it does not stop your
logimport from reading the wrong slice if you trust an attacker-suppliedlen. Clamp it, as the import above does.
Performance note
The expensive part of this hardening is one-time, not per-call. Hash verification of a 200 KiB module takes
well under a millisecond with crypto.subtle.digest, and import allowlist checks run once at instantiation.
Capping maximum memory has zero runtime cost — it only bounds memory.grow. The single ongoing cost is
running an untrusted module in a Worker, which adds postMessage serialization; for that, share a
SharedArrayBuffer to avoid copying large payloads, accepting the cross-origin-isolation requirement that
comes with it.
Frequently Asked Questions
Can a malicious .wasm escape the browser sandbox and read host memory?
No. Every memory access is bounds-checked into the module’s own linear memory, return addresses live in
protected engine state, and indirect calls are type-checked. The realistic risk is a module abusing the
capabilities you imported, not breaking out of the engine.
Do I need different controls for server-side Wasm (e.g. WASI on the edge)? Yes. Browser controls assume the engine grants no filesystem or network by default; WASI runtimes grant those through host functions and capability flags. The same allowlist discipline applies, but the budget includes file and socket capabilities you must scope explicitly.
How do I map this audit to SOC 2 or ISO 27001 evidence?
Log the binary’s SHA-256, its import signature, and its memory maximum to your SIEM on every deploy, and
treat a failing wasm-validate or allowlist test as a control failure. That gives you an auditable trail
tying each shipped artifact to a reviewed, bounded capability set.
Related
- Browser sandbox & security boundaries — the capability model and bounds-checking these controls build on.
- Is WebAssembly faster than JavaScript for DOM manipulation? — boundary costs that shape where you run untrusted compute.
- Wasm binary format deep dive — the structural validation behind
wasm-validate.
← Back to Browser Sandbox & Security Boundaries