Producing Reproducible Wasm Binaries
This page answers one task: make the WebAssembly module built from a given commit byte-for-byte identical no matter who builds it or where, and prove it with a check that fails when that stops being true.
Prerequisites
- [ ] A pinned toolchain — see pinning Wasm toolchain versions. Reproducibility is impossible if the compiler differs.
- [ ]
sha256sum(orshasum -a 256on macOS) andwasm-objdumpfrom WABT. - [ ] Two places to build: two directories on one machine is enough to start.
Why it matters for Wasm in particular
A reproducible build lets anyone verify that a published binary came from the source it claims to. That matters
more for WebAssembly than for most web assets because a .wasm file is opaque to casual review: a JavaScript
bundle can at least be skimmed, a binary cannot. If you publish a module to npm, ship it inside a browser
extension, or let users run it on sensitive data, the strongest statement you can make is “build this commit
and compare the hash”.
It also makes everyday engineering easier. Size-regression checks compare real changes rather than noise. Caches keyed on output hashes hit. And a bug report that includes the module’s hash identifies exactly which build the user has.
Where nondeterminism sneaks in
Compilers are deterministic given identical inputs. The trouble is that “inputs” quietly includes things that are not your source: the absolute path of the checkout, the build machine’s username inside a home directory, the current time read by a build script, and the version strings tools stamp into the binary.
Step 1 — build twice and compare
Before fixing anything, find out how far from reproducible the build is. Two clean checkouts in different directories reveal path leakage immediately.
git clone . /tmp/build-a && git clone . /tmp/build-b
(cd /tmp/build-a && wasm-pack build --release --target web)
(cd /tmp/build-b && wasm-pack build --release --target web)
sha256sum /tmp/build-a/pkg/*.wasm /tmp/build-b/pkg/*.wasm
If the hashes differ, find out where:
cmp -l /tmp/build-a/pkg/app_bg.wasm /tmp/build-b/pkg/app_bg.wasm | head
strings -n 8 /tmp/build-a/pkg/app_bg.wasm | grep -E '/tmp/build-a|/home/' | head
The strings search almost always finds the culprit on a first attempt: the checkout path, embedded by panic
location information.
Step 2 — remap source paths
Rust embeds file paths for panic messages (src/lib.rs:42:9) and in debug information. By default those are
absolute for dependencies in the cargo registry, which contains your username. Remap the prefixes to stable
placeholders:
# .cargo/config.toml
[target.wasm32-unknown-unknown]
rustflags = [
"--remap-path-prefix=/home/runner/work/app/app=/build",
"-Ctrim-paths=all", # nightly; stable alternative below
]
On stable, set the remapping from the environment so it adapts to whatever directory the build runs in:
export RUSTFLAGS="--remap-path-prefix=$PWD=/build --remap-path-prefix=$HOME/.cargo=/cargo"
cargo build --release --target wasm32-unknown-unknown
For C and C++ through clang or emcc, the equivalent flags are -ffile-prefix-map=$PWD=/build and
-fdebug-prefix-map. The goal is the same: no string in the output should depend on where the source lived.
Step 3 — strip or stabilise the producers section
Many toolchains write a producers custom section listing the language, the compiler and the tool versions.
It is harmless metadata, but it changes whenever any tool’s version string does — including patch releases and
locally built tools that append a commit hash.
wasm-objdump -x -j producers pkg/app_bg.wasm
Custom:
- name: "producers"
- language: Rust ""
- processed-by: rustc "1.81.0 (eeb90cda1 2024-09-04)"
- processed-by: wasm-bindgen "0.2.93 (bf4da8a1a)"
With pinned tools this section is stable and can stay. If you cannot guarantee identical tool builds — or you do not want to publish your toolchain — strip it in the optimize step:
wasm-opt -Oz --strip-producers --strip-debug pkg/app_bg.wasm -o pkg/app_bg.wasm
Step 4 — remove timestamps from build scripts
A build.rs that embeds the build time for a version banner is the classic reproducibility bug. Read the
time from SOURCE_DATE_EPOCH when it is set, which is the cross-ecosystem convention for “pretend it is this
moment”:
// build.rs
use std::time::{SystemTime, UNIX_EPOCH};
fn main() {
let ts = std::env::var("SOURCE_DATE_EPOCH")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or_else(|| SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs());
println!("cargo:rustc-env=BUILD_TIMESTAMP={ts}");
println!("cargo:rerun-if-env-changed=SOURCE_DATE_EPOCH");
}
export SOURCE_DATE_EPOCH=$(git log -1 --format=%ct) # the commit's own time
Using the commit timestamp keeps the banner meaningful — it still tells you roughly when the code was written — while making it a function of the source.
Step 5 — check reproducibility in CI
A property nobody checks erodes. Build twice in CI, in different directories, and fail if the hashes differ:
reproducible:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { path: a }
- uses: actions/checkout@v4
with: { path: b }
- run: |
export SOURCE_DATE_EPOCH=$(git -C a log -1 --format=%ct)
for d in a b; do
(cd $d && RUSTFLAGS="--remap-path-prefix=$PWD=/build --remap-path-prefix=$HOME/.cargo=/cargo" \
wasm-pack build --release --target web)
done
sha256sum a/pkg/*.wasm b/pkg/*.wasm
cmp a/pkg/app_bg.wasm b/pkg/app_bg.wasm
Building in two directories on the same runner catches path leaks. To catch timestamp leaks, add a sleep 61
between builds or run the second build in a separate job and compare uploaded artifacts.
Step 6 — publish what a verifier needs
Reproducibility only becomes a security property when someone outside the team can check it. That requires publishing, with each release, everything a verifier needs to rebuild: the commit hash, the exact toolchain (ideally as a container image digest rather than a tag, since tags can be moved), the build command, the environment variables that affect output, and the expected hash of every shipped file.
release: v2.4.0
commit: 9c41e7a0d5b2f3c8e1a6b7d9f0c2e4a8b1d3f5e7
builder image: ghcr.io/acme/wasm-builder@sha256:5b8e2c...
command: just release
env: SOURCE_DATE_EPOCH=1727712000
sha256:
app_bg.wasm 3c7e0a91b2f4d81c...
app.js a1f09c3e77b25e04...
A file like this, attached to the release and signed, turns “trust us” into “check us”. It is also valuable internally: when a user reports a bug against a particular hash, the manifest tells you exactly which commit and toolchain produced the module they are running, without guesswork about which branch was deployed when.
There is a practical side benefit to doing this work that has nothing to do with security. Once two builds produce identical bytes, any difference between two modules is a real change. Content-hashed filenames stop changing on commits that touched only documentation, so browser and CDN caches stay warm across deploys that did not alter the module. Size-tracking dashboards stop showing noise. And when a bisect finds the commit that made a module slower, you can be confident that the build of that commit is the same build users received.
Expected output
3c7e0a91b2f4... a/pkg/app_bg.wasm
3c7e0a91b2f4... b/pkg/app_bg.wasm
cmp prints nothing and exits zero when the files are identical.
Gotchas
- The JavaScript glue differs but the module does not. wasm-bindgen output is deterministic, but a bundler
may embed paths or hashes that depend on the directory. Check the
.jsfiles too, and configure the bundler for relative paths. - Hashes match locally, differ in CI. The toolchain differs. Print every tool’s version in both places; the
mismatch is usually a patch-level difference in
wasm-opt. --remap-path-prefixset in one place, overridden in another.RUSTFLAGSfrom the environment replacesrustflagsfrom.cargo/config.tomlrather than adding to it. Use one mechanism.- Locale or timezone leaking into generated code. A build script that formats a date or sorts strings with
locale-aware comparison produces different output on differently configured machines. Set
LC_ALL=CandTZ=UTCin the build environment. - Dependencies with build scripts that embed paths. Some
-syscrates do. Find them with thestringssearch and report them upstream; remapping usually covers them.
Performance note
Reproducibility costs nothing at runtime and almost nothing at build time — the remapping and stripping are free, and the second CI build can reuse the cargo cache. On one module, stripping the producers section and remapped paths together removed 1.8 KB, because absolute registry paths in panic messages are long. Shorter paths are a small, free size win, and the same idea taken further is described in removing panic and formatting bloat.
Frequently Asked Questions
Is reproducibility possible with Emscripten builds? Yes, with the same ingredients: a pinned emsdk, prefix maps for paths, and no timestamps. Emscripten’s own output is deterministic for identical inputs.
Should debug builds be reproducible too? It is less important — debug builds are not distributed — and DWARF makes it harder. Focus on release artifacts.
Does wasm-opt introduce nondeterminism?
Not for identical inputs and versions — Binaryen’s passes are deterministic. Differences attributed to
wasm-opt almost always turn out to be two different releases, or a different input coming from an earlier
step.
How do users verify a published module? Publish the commit, the toolchain manifest and the expected hash with each release. Anyone with the same container image can rebuild and compare, which is the main argument for building in a pinned Docker image.
Related
- Building Wasm in Docker for consistent output — the environment half of reproducibility.
- Auditing third-party Wasm binaries — why verifiable builds matter to consumers.
- Reading the name custom section — another custom section that varies with build settings.
- Catching size regressions in CI — size checks are only meaningful on deterministic builds.
← Back to Cross-Platform Build Automation