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.
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.
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.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
openssl dgstwithout-binarygives you the wrong digest. The default output is lowercase hex text. Piping that intoopenssl base64encodes the characters of the hex string, producing a 96-character-derived value that is roughly twice the correct length and will never matchcrypto.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. Anas="fetch"preload is issued in CORS mode; if the attribute is missing, the credentials mode of the preload will not match the laterfetch()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 withoutcrossorigin="anonymous"yields an opaque response the browser refuses to verify, so the resource is blocked outright. -
A wrong
Content-Typefails only the streaming entry points.WebAssembly.instantiateStreaming()andcompileStreaming()reject with aTypeErrorreading roughly Incorrect response MIME type. Expected ‘application/wasm’.WebAssembly.compile()on anArrayBufferdoes 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
Instanceis not the cacheable unit. Caching aWebAssembly.Instanceand handing it to two independent consumers shares one linear memory between them, which produces corruption that looks like a compiler bug. Cache theModule; callWebAssembly.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.
Related
Permalink to "Related"- Adding Integrity to Web Worker Scripts — the same missing-attribute problem for
new Worker(), and the blob-URL pattern that works around it - Service Worker Cache Integrity Checks — verifying entries already sitting in the Cache Storage API before they are served
- Verifying Deployed Assets Against a Hash Manifest — checking the same manifest from CI so a mismatch is caught before a user hits it