Verifying WebAssembly Module Hashes

Permalink to "Verifying WebAssembly Module Hashes"

Part of Integrity for Workers & WebAssembly, this guide covers the one loading path where the integrity attribute simply does not exist: a .wasm binary pulled down by script and handed straight to the WebAssembly compiler.

Quick Reference

Permalink to "Quick Reference"
Mechanism Syntax Verifies bytes? Streams?
Streaming instantiate WebAssembly.instantiateStreaming(fetch(url), imports) No Yes
Fetch integrity option fetch(url, { integrity: 'sha384-…' }) Yes, by the browser No
Manual digest crypto.subtle.digest('SHA-384', bytes) Yes, by your code No
Preload hint <link rel="preload" as="fetch" crossorigin="anonymous"> No n/a
Required response header Content-Type: application/wasm n/a Required for streaming
Digest generation openssl dgst -sha384 -binary f.wasm | openssl base64 -A Build time n/a
Compile cache unit WebAssembly.Module (not Instance) n/a n/a

Default choice: buffer, digest with SHA-384, compare, compile, and cache the resulting WebAssembly.Module.

The mental model

Permalink to "The mental model"

WebAssembly.instantiateStreaming() is designed around a single idea — start compiling machine code while the network is still delivering bytes. It takes a Response (or a promise for one) and feeds the body into the compiler incrementally. That design is exactly why it has no integrity hook. A cryptographic digest is a function of the complete input; you cannot know whether a 4 MB module hashes to the value you expect until the four-millionth byte has landed. By then the compiler has already turned the first 3.9 MB into executable code. There is no point in the pipeline where a verdict could be applied that would not defeat the reason the API exists.

So the tradeoff is not a limitation to work around. It is arithmetic. Any path that verifies a module before compiling it buffers the module first. The practical question is only who does the buffering and the comparison: the browser, when you pass integrity to fetch(), or your own code, when you call response.arrayBuffer() and digest it yourself. Both give up streaming compilation. Neither is meaningfully slower than the other in wall-clock terms for modules under a few megabytes, because the digest itself is fast — SHA-384 over a 4 MB buffer is single-digit milliseconds on a modern device, dwarfed by the compile.

Doing it by hand costs a few dozen lines and buys three things the built-in option does not give you: a failure you can catch and route (fall back to a bundled copy, report to telemetry, degrade the feature), a digest you can log for forensics, and the buffer already in hand for a WebAssembly.Module you intend to cache.

It is worth being precise about what the check defends against, because WebAssembly modules attract a lot of hand-waving. The digest comparison protects the transport and the storage in between: a compromised CDN edge, a poisoned intermediate cache, a stale object left behind by a half-finished deploy, an origin that has been rewritten. It says the bytes the compiler sees are byte-for-byte the bytes the build produced. It says nothing at all about what the build produced, and it says nothing about the JavaScript that calls instantiateVerified() — that code can pass whatever imports it likes into the module, and a module is only as constrained as its import object. Treat the digest as one control among several rather than as the boundary itself.

Streaming versus buffered WebAssembly loading The streaming path passes a fetch response straight into instantiateStreaming and produces an instance from unverified bytes. The buffered path reads the whole response into an ArrayBuffer, digests it with SHA-384, compares the base64 result to an expected value, and only then compiles. A · streaming path — nothing computes a digest fetch(url) response stream Content-Type application/wasm WebAssembly instantiateStreaming Instance unverified bytes any byte sequence the origin returns is compiled and instantiated B · buffered path — hash, compare, then compile fetch(url) credentials omit await arrayBuffer() crypto.subtle digest SHA-384 base64 compare to expected WebAssembly compile(bytes) compilation begins only after the digests match

Canonical example: a verified instantiate helper

Permalink to "Canonical example: a verified instantiate helper"

The expected digest is produced once, at build time, by the same command you would use for any other asset. SHA-384 is the default here for the same reason it is the default elsewhere on this site — see SHA-256 vs SHA-384 vs SHA-512 for SRI for the reasoning.

# Emit the digest exactly as it will appear in the manifest
printf 'sha384-%s\n' "$(openssl dgst -sha384 -binary dist/app.wasm | openssl base64 -A)"

The -binary flag matters: without it openssl dgst prints hex, and base64-encoding hex text produces a string that will never match. -A keeps the base64 on one line instead of wrapping at 64 characters. The same pipeline is described in more depth in Generating SRI Hashes with OpenSSL and shasum, and the encoding rules that make a digest string valid or invalid are covered in Base64 Encoding Rules for SRI Hashes.

Write that line into a manifest your build emits alongside the binary:

{
  "/wasm/app.wasm": "sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
}

Then the runtime helper. It buffers, digests, compares, and only afterwards compiles:

// wasm-verify.js — module-scoped so the cache is shared across importers
import manifest from './wasm-manifest.json' with { type: 'json' };

const compiled = new Map();

function toBase64(buffer) {
  const bytes = new Uint8Array(buffer);
  let binary = '';
  for (const byte of bytes) binary += String.fromCharCode(byte);
  return btoa(binary);
}

async function compileVerified(url) {
  const expected = manifest[url];
  if (!expected) throw new Error(`wasm-verify: no digest recorded for ${url}`);

  const separator = expected.indexOf('-');
  const algorithm = expected.slice(0, separator);   // "sha384"
  const wanted = expected.slice(separator + 1);     // base64 digest
  if (algorithm !== 'sha384') throw new Error(`wasm-verify: unsupported ${algorithm}`);

  const response = await fetch(url, { credentials: 'omit' });
  if (!response.ok) throw new Error(`wasm-verify: ${url} returned HTTP ${response.status}`);

  const contentType = (response.headers.get('content-type') ?? '').split(';')[0].trim();
  if (contentType !== 'application/wasm') {
    throw new Error(`wasm-verify: ${url} served as "${contentType}", expected application/wasm`);
  }

  // Buffering is unavoidable: the digest is a function of the whole binary.
  const bytes = await response.arrayBuffer();
  const actual = toBase64(await crypto.subtle.digest('SHA-384', bytes));

  if (actual !== wanted) {
    throw new Error(`wasm-verify: digest mismatch for ${url}\n  expected sha384-${wanted}\n  received sha384-${actual}`);
  }

  return WebAssembly.compile(bytes);
}

export function instantiateVerified(url, imports = {}) {
  if (!compiled.has(url)) compiled.set(url, compileVerified(url));
  return compiled.get(url).then((module) => WebAssembly.instantiate(module, imports));
}

Two details are load-bearing. The cache stores the promise, not the resolved module, so two callers racing on first load share one fetch instead of issuing two. And it caches a WebAssembly.Module rather than an Instance: a module is the compiled, immutable code object and can be instantiated any number of times, while an instance owns mutable linear memory and must never be shared between logical consumers.

Note also that crypto.subtle only exists in a secure context. On http:// origins other than localhost the property is undefined and the helper throws a TypeError on the digest call — which is the correct outcome, since integrity checking over plaintext HTTP is theatre anyway.

The toBase64 loop deserves a word. crypto.subtle.digest() resolves to an ArrayBuffer, and btoa() wants a binary string, so the bytes have to be walked into character codes first. Forty-eight bytes is small enough that the naive loop is free; do not reach for String.fromCharCode(...new Uint8Array(buffer)) as a shortcut on larger buffers, because spreading a large typed array into an argument list overflows the call stack. The output alphabet is standard base64 — A–Z, a–z, 0–9, + and / — which is what the SRI grammar expects, and notably not the URL-safe variant some hashing libraries emit by default. If your build tool hands you a digest containing - or _, it is base64url and it will never compare equal to what btoa() produces.

One more design decision hides in the helper: the manifest is keyed by the exact URL string that the caller passes. That works because both sides of the comparison come from the same build. If your URLs carry a content hash in the filename already, the manifest key and the digest are two expressions of the same fact and a mismatch means the deploy is internally inconsistent — a useful alarm in its own right.

Build-to-runtime verification sequence The build pipeline publishes the wasm binary and its digest manifest to the origin. The browser requests the module, receives it with an application/wasm content type, digests the buffered bytes with SHA-384, and either throws on a mismatch or compiles on a match. build / CI origin or CDN browser publish .wasm + digest manifest GET /wasm/app.wasm 200 application/wasm await crypto.subtle.digest ('SHA-384', arrayBuffer) digest differs throw before compiling digest matches WebAssembly.compile(bytes)

Variants

Permalink to "Variants"

Let the browser do the comparison with the fetch integrity option

Permalink to "Let the browser do the comparison with the fetch integrity option"

fetch() accepts SRI metadata directly. The browser reads the body, checks the digest, and rejects the promise with a TypeError if it does not match:

const response = await fetch('/wasm/app.wasm', {
  integrity: 'sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC',
  credentials: 'omit'
});
const { instance } = await WebAssembly.instantiate(await response.arrayBuffer(), imports);

This is the shortest correct answer when you do not need a custom failure path. Be aware that passing integrity silently disables streaming: the specification requires the response body to be fully consumed and verified before it is handed to the caller, so wrapping this in instantiateStreaming() buys nothing. It also means a cross-origin module must be CORS-readable — an opaque response cannot be verified, a rule shared with every other SRI-protected fetch and explained in How CORS and crossorigin Affect SRI.

Preload the module without pretending it is verified

Permalink to "Preload the module without pretending it is verified"

A preload is worth adding when the module is on the critical path, because it starts the transfer during HTML parsing rather than after your bundle executes:

<link rel="preload" href="/wasm/app.wasm" as="fetch" type="application/wasm" crossorigin="anonymous">

The crossorigin="anonymous" is not optional here even for a same-origin file: as="fetch" requests are made in CORS mode, and a preload whose CORS mode does not match the later fetch() will be discarded and re-requested, doubling the download. What the preload does not do is verify anything. Enforcement of an integrity attribute on rel="preload" is inconsistent across engines, and even where it is honoured the verdict is not propagated to your fetch() call — a dropped preload just means the network request happens again. The digest comparison has to live in code that runs before WebAssembly.compile(). Treat the tag as a latency optimisation and nothing more.

Fall back to a bundled copy on mismatch

Permalink to "Fall back to a bundled copy on mismatch"

Because the manual helper throws a normal Error, the caller can choose what a failure means:

let wasm;
try {
  wasm = await instantiateVerified('/wasm/app.wasm', imports);
} catch (error) {
  navigator.sendBeacon('/telemetry/wasm-integrity', JSON.stringify({ url: '/wasm/app.wasm', message: error.message }));
  wasm = null; // feature stays off; the app renders its non-accelerated path
}

Reporting the mismatch matters more than recovering from it. A digest mismatch on a static, content-addressed file is either a stale deploy or a tampered edge cache, and both are incidents.

Choosing a WebAssembly loading shape A decision tree branching from the question of which fetch shape is used into three outcomes: streaming instantiation checks no digest, the fetch integrity option verifies in the browser at the cost of streaming, and a manual arrayBuffer digest verifies in your code and yields a cacheable module. which fetch shape are you using for the module? instantiateStreaming (fetch(url)) fetch(url, { integrity: 'sha384-...' }) fetch(url) then arrayBuffer() no digest is checked at any point browser verifies; no stream compile you verify; module is cacheable

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • openssl dgst without -binary gives you the wrong digest. The default output is lowercase hex text. Piping that into openssl base64 encodes the characters of the hex string, producing a 96-character-derived value that is roughly twice the correct length and will never match crypto.subtle.digest(). A correct SHA-384 digest is exactly 64 base64 characters with no = padding, because 48 bytes divides evenly into three-byte groups.

  • Omitting crossorigin="anonymous" on the preload wastes the download. An as="fetch" preload is issued in CORS mode; if the attribute is missing, the credentials mode of the preload will not match the later fetch() and the browser discards the preloaded response and requests the file again. The same rule governs every <script integrity="…"> tag — an integrity check on a cross-origin response without crossorigin="anonymous" yields an opaque response the browser refuses to verify, so the resource is blocked outright.

  • A wrong Content-Type fails only the streaming entry points. WebAssembly.instantiateStreaming() and compileStreaming() reject with a TypeError reading roughly Incorrect response MIME type. Expected ‘application/wasm’. WebAssembly.compile() on an ArrayBuffer does not care at all — which means the manual helper will happily compile a module the server mislabelled. That is why the helper above checks the header explicitly rather than relying on the API to do it.

  • The Instance is not the cacheable unit. Caching a WebAssembly.Instance and handing it to two independent consumers shares one linear memory between them, which produces corruption that looks like a compiler bug. Cache the Module; call WebAssembly.instantiate(module, imports) per consumer.

  • A digest check is not a signature check. Verifying the module against a digest you shipped proves the bytes match what your build produced. It proves nothing about whether your build produced something safe, and it is worthless if the attacker can also rewrite the manifest. Keep the manifest in the same signed, immutable deploy as the HTML that loads it.

Verification Steps

Permalink to "Verification Steps"

1. Confirm the digest your build records

Permalink to "1. Confirm the digest your build records"
openssl dgst -sha384 -binary dist/app.wasm | openssl base64 -A; echo

Expected output is a single unpadded 64-character base64 line:

oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC

Paste the same value into the browser console via the helper’s error message on a deliberate mismatch and confirm the received line is identical to this one.

2. Confirm the response headers

Permalink to "2. Confirm the response headers"
curl -sI https://example.com/wasm/app.wasm | grep -i -E 'content-type|access-control-allow-origin'

Expected output for a cross-origin module:

content-type: application/wasm
access-control-allow-origin: *

If content-type reads application/octet-stream or text/html, add the mapping to the server. On nginx that is a single line in mime.types or a types { application/wasm wasm; } block.

3. Confirm the failure path actually blocks compilation

Permalink to "3. Confirm the failure path actually blocks compilation"

Corrupt a copy of the binary and load it through the helper:

cp dist/app.wasm dist/app-tampered.wasm
printf '\x00' | dd of=dist/app-tampered.wasm bs=1 seek=64 conv=notrunc status=none

Point the manifest entry at the tampered file and reload. Expected console output:

Error: wasm-verify: digest mismatch for /wasm/app-tampered.wasm
  expected sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC
  received sha384-9Bl7tCTLnDpPeVoTqUt2xQeGYYzYAqfP0vXBEE4uOUTs5RJ0lYQ8jd7Yhb7Umv1O

The important signal is what is absent: no WebAssembly.compile() call appears in the performance profile, and no instance exports are reachable.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Why can I not put an integrity attribute on a WebAssembly module?

The integrity attribute belongs to elements the HTML parser fetches, such as script and link. A WebAssembly module is loaded by script, through fetch, so there is no element to carry the metadata. The Fetch API exposes an integrity option instead, and everything else has to be done by hand with a digest comparison before compilation.

Does verifying the hash cost me streaming compilation?

Yes. A digest cannot be computed until the final byte has arrived, so any verified path buffers the whole module first. That is true of the manual crypto.subtle route and equally true of the fetch integrity option, because a fetch with integrity metadata reads the body to completion before releasing it. Streaming compilation and pre-compilation verification are mutually exclusive.

What Content-Type must the server send for a .wasm file?

Exactly application/wasm. The streaming entry points reject any other MIME type with a TypeError before compilation starts, and a misconfigured static host commonly serves application/octet-stream or text/html for an unknown extension. Add the mapping in your server’s MIME table, then confirm it with a HEAD request rather than trusting the config.

Can I cache the verified module so the hash check runs only once?

Yes, and you should. Store the promise returned by the verifying compile step in a Map keyed by URL. Every later caller awaits the same promise, so the fetch, the digest and the compile all happen once per page. Cache the WebAssembly.Module, never the Instance, because each instance owns its own mutable memory.

Does a preload link with an integrity attribute protect the module?

No. A preload is a scheduling hint that warms the cache; it does not gate the later fetch that actually reads the bytes. Support for enforcing integrity on a preload is uneven, and even where it exists nothing propagates the verdict to your fetch call. Treat the preload as a latency optimisation and keep the digest check in code.

Permalink to "Related"

Related Articles

Adding Integrity to Web Worker Scripts
Service Worker Cache Integrity Checks
Integrity for Workers & WebAssembly Core SRI Fundamentals & Browse…