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) and binaryen (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/wasm MIME type
  • [ ] An inventory of every .wasm artifact 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-eval smuggled in alongside Wasm. Teams sometimes add 'unsafe-eval' to make Wasm compile, which silently re-enables JavaScript eval() and new Function() across the app. Use 'wasm-unsafe-eval' — it permits only WebAssembly.

  • COEP: require-corp breaks third-party assets. Once you enable cross-origin isolation, every CDN font, image, or analytics script without a Cross-Origin-Resource-Policy header fails to load and crossOriginIsolated flips to false. 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 .wasm bytes 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 log import from reading the wrong slice if you trust an attacker-supplied len. 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.

← Back to Browser Sandbox & Security Boundaries