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 (or shasum -a 256 on macOS) and wasm-objdump from 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.

Sources of nondeterminism in a Wasm build Five layers that can make two builds of the same commit differ — embedded absolute paths, build timestamps, the producers custom section, toolchain differences, and unordered iteration in build scripts — each with its fix. absolute paths panic messages and DWARF embed /home/alice/... — remap with --remap-path-prefix timestamps build.rs or a version module reads the clock — use SOURCE_DATE_EPOCH producers section tool names and versions stamped in — strip it or pin the tools toolchain drift a different wasm-opt reorders code — pin every tool build-script ordering HashMap iteration or directory listing order — sort before emitting

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
The reproducibility check in CI Two checkouts of the same commit in different directories are built with the same environment. Their module hashes are compared and the job fails if any byte differs, catching new path or timestamp leaks as soon as they are introduced. checkout a/ same commit checkout b/ different path build both same env and flags cmp bytes fail on any diff Different directories catch path leaks; for timestamp leaks, run the second build a minute later or on another runner.

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.

Byte differences between two builds, fix by fix The number of differing bytes between builds of the same commit in two directories, as each fix is applied. Path remapping removes most of the difference, producers stripping and SOURCE_DATE_EPOCH remove the rest. bytes that differ between build a/ and build b/ no fixes 2,184 bytes + path remapping 96 bytes + strip producers 41 bytes + SOURCE_DATE_EPOCH 0 bytes The remaining 41 bytes before the last fix were a build-time banner embedded by one build script.

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 .js files 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-prefix set in one place, overridden in another. RUSTFLAGS from the environment replaces rustflags from .cargo/config.toml rather 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=C and TZ=UTC in the build environment.
  • Dependencies with build scripts that embed paths. Some -sys crates do. Find them with the strings search 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.

← Back to Cross-Platform Build Automation