Verifying Wasm Integrity Before Instantiation

This page answers one task: when a page loads a .wasm file from a CDN, a cache or any server you do not fully control, refuse to run it unless its bytes match the module you built.

Prerequisites

  • [ ] A build that produces the final .wasm file deterministically, so its hash is meaningful.
  • [ ] A place in your HTML or JavaScript to embed the expected hash at build time.
  • [ ] Modules served with CORS headers if they come from another origin (integrity checks on cross-origin fetches require CORS).

What integrity checking protects against

Scripts loaded with <script src> from a CDN can carry an integrity attribute: the browser hashes the downloaded file and refuses to run it unless the hash matches. That is Subresource Integrity, and it defends against a compromised or misconfigured CDN serving something other than what you published. WebAssembly modules are usually loaded with fetch rather than a tag, so they do not get SRI automatically — a tampered module from the same CDN would compile and run with the page’s full privileges.

The WebAssembly sandbox limits what a malicious module can do by itself: it cannot touch the DOM or the network except through the imports you give it. But modules are given imports that matter — functions that write to the page, send messages, read storage — and a module that misuses them is as dangerous as a malicious script. Verifying the bytes before compiling them closes the gap SRI closes for scripts.

Loading a module with an integrity check The expected SHA-256 hash is embedded in the page at build time. At runtime the page fetches the module with integrity metadata; the browser hashes the response and either hands the verified bytes to streaming compilation or rejects the fetch. build step hash app.wasm → sha256-… page embeds hash in JS or HTML fetch(url, {integrity}) browser hashes body match → compile instantiateStreaming mismatch → reject TypeError, no code runs

Step 1 — generate the hash at build time

The expected hash must come from your build, not from the server that serves the file — otherwise an attacker who controls the file controls the hash too. Generate it alongside the module:

hash=$(openssl dgst -sha384 -binary dist/app.wasm | openssl base64 -A)
echo "sha384-$hash" > dist/app.wasm.integrity

SRI accepts sha256, sha384 and sha512; sha384 is the common choice for scripts and works the same for Wasm. For bundler-based builds, emit the hash into a small generated module the loader imports:

// build/write-integrity.mjs
import { createHash } from "node:crypto";
import { readFile, writeFile } from "node:fs/promises";
const bytes = await readFile("dist/app.wasm");
const value = "sha384-" + createHash("sha384").update(bytes).digest("base64");
await writeFile("src/wasm-integrity.js", `export const APP_WASM_INTEGRITY = ${JSON.stringify(value)};\n`);

Step 2 — pass integrity to fetch

fetch accepts the same integrity metadata as a script tag. The browser hashes the response body and rejects the promise on mismatch, and because the check happens inside fetch, streaming compilation still works on the verified response:

import { APP_WASM_INTEGRITY } from "./wasm-integrity.js";

const url = "https://cdn.example.com/app-3f9a1c.wasm";
const response = fetch(url, { integrity: APP_WASM_INTEGRITY, mode: "cors", credentials: "omit" });
const { instance } = await WebAssembly.instantiateStreaming(response, imports);

Cross-origin integrity checks require CORS: the CDN must send Access-Control-Allow-Origin, and the request must use mode: "cors". Without CORS the browser cannot read the body to hash it, and the fetch fails. Same-origin modules need nothing extra.

The browser buffers enough to verify before releasing the body, so for very large modules integrity can delay the start of compilation until the download completes — measured in the performance note below.

Step 3 — or verify manually with Web Crypto

When you need more control — custom error handling, verification of bytes obtained another way (from IndexedDB, from a service worker cache, from a postMessage) — hash the bytes yourself before compiling:

async function verifiedBytes(source, expectedHex) {
  const bytes = new Uint8Array(await (await fetch(source)).arrayBuffer());
  const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
  const hex = [...digest].map((b) => b.toString(16).padStart(2, "0")).join("");
  if (hex !== expectedHex) throw new Error(`module integrity check failed for ${source}`);
  return bytes;
}

const bytes = await verifiedBytes("/wasm/app.wasm", "4f1c9a0e7b3d2c51...");
const { instance } = await WebAssembly.instantiate(bytes, imports);

This gives up streaming compilation — the bytes are verified as a whole before compiling — which is a reasonable trade for modules from caches, where download time is negligible anyway. Modules cached in IndexedDB, as in caching compiled Wasm modules in IndexedDB, are a good candidate: storage can be modified by other scripts on the same origin.

Step 4 — fail safely

Decide in advance what the page does when verification fails. The safe default is to not run the feature and to report the failure, because a mismatch means either a deployment mistake or tampering, and both deserve attention:

try {
  instance = await loadVerified();
} catch (err) {
  reportError({ kind: "wasm-integrity", url, message: String(err) });   // to your error tracker
  showFeatureUnavailable();                                              // degrade, do not retry blindly
}

Do not fall back to loading the module without verification. That turns a security check into a performance optimization that an attacker can bypass by making it fail.

Choosing how to verify a module Modules fetched over the network should use fetch integrity metadata, which keeps streaming compilation. Bytes obtained from a cache, storage or another context should be hashed manually with Web Crypto. Either way, a mismatch should stop the feature and be reported. Where do the module bytes come from? network fetch fetch integrity option streaming compile still works cache, storage, message Web Crypto digest verify whole bytes, then compile verification fails Stop and report never fall back to unverified

Where integrity checks fit in a defence

Integrity checking is one layer of several, and it is worth being clear about which threats it addresses so it is neither over- nor under-trusted. It defends against a compromised or misconfigured delivery path: a CDN account taken over, an object store with the wrong permissions, a caching proxy that serves a stale or altered file, an internal mirror that drifted. In each of those cases the page’s own code is intact and the module is not, and the hash catches the difference before any of the module’s code runs.

It does not defend against a compromise of the page itself. If an attacker can change the HTML or the JavaScript that contains the expected hash, they can change the hash too. That is why the page and its loader should be served from your own origin with a strict Content Security Policy, while the large binary assets can come from anywhere — the check moves trust from the delivery path to the much smaller document that names the expected bytes.

And it does not tell you whether the module you built is safe. A correct hash proves the bytes are the ones you published, not that those bytes behave well. Reviewing what a module imports and exports, especially a third-party one, is a separate step covered in auditing third-party Wasm binaries.

Step 5 — keep hashes and files in step

Integrity checking fails loudly when the file and the hash disagree, so deployments must update both together. Content-hashed file names — app-3f9a1c.wasm — make this natural: a new build produces a new file name and a new hash, and old pages keep loading the old file with the old hash. Overwriting a file at a fixed URL while pages with the old hash are still cached is the classic way to break every returning user at once; see versioning Wasm files with content hashes.

Expected output

With a correct hash, the module loads exactly as before. With a tampered file — change one byte and redeploy — the fetch rejects:

TypeError: Failed to fetch
Failed to find a valid digest in the 'integrity' attribute for resource 'https://cdn.example.com/app-3f9a1c.wasm' with computed SHA-384 integrity 'q8N...'. The resource has been blocked.

No compilation happens and no code from the module runs.

Gotchas

  • Every request fails after a deploy. The hash in the page was generated from a different build than the deployed file. Generate both in the same build step.
  • Cross-origin fetch fails with integrity. The CDN does not send CORS headers. Integrity needs a readable response.
  • A compressing proxy breaks the hash. The hash covers the decoded body, so Content-Encoding is fine; a proxy that rewrites the content is not. Disable transformations for .wasm (Cache-Control: no-transform).
  • Service worker responses. A service worker that constructs responses must preserve the bytes exactly; integrity checks apply to what the page receives.

Performance note

Hashing is fast — SHA-384 over a 3 MB module took about 4 ms on a mid-range phone. The larger effect is on streaming: with integrity metadata, Chrome released the body to the compiler only after verifying, so compilation could not overlap the download. For a 3 MB module over a fast connection the difference was about 60 ms; on slow connections, where download dominates, it was proportionally smaller.

Time to ready for a 3 MB module, with and without integrity The same module loaded with streaming compilation, with fetch integrity metadata, and with a manual Web Crypto check, on a mid-range phone over Wi-Fi. ms from fetch start to instance ready no integrity check 410 ms fetch integrity option 468 ms manual SHA-256 then instantiate 522 ms

Frequently Asked Questions

Is HTTPS not enough? HTTPS protects the connection between the browser and the server. It does not protect against the server — or the CDN — serving the wrong file. Integrity checks cover that case.

Should same-origin modules be verified? It is less important, since a compromise of your own origin can also change the page that holds the hash. Verification still catches deployment mistakes such as mixed versions.

Can the module verify itself? No — anything inside the module is under the attacker’s control if the module is tampered with. Verification must happen before compilation, in code you trust.

What about signatures instead of hashes? Signatures let you rotate modules without updating the page, at the cost of embedding a public key and verifying a signature; see verifying Ed25519 signatures in Wasm.

← Back to Browser Sandbox & Security Boundaries