Validating Binaries with wasm-validate
This guide answers one task: verify that a .wasm file is well-formed and uses only the features your
target supports, as a build step that fails before a broken artifact reaches anyone.
Prerequisites
- [ ] The WebAssembly Binary Toolkit, which provides
wasm-validateandwasm-objdump. - [ ] A built module to check.
- [ ] A list of the features your deployment targets support.
- [ ] A build script where the check can live.
What validation proves, and what it does not
Validation is a structural and type check defined by the specification. It confirms that the sections are well-formed, that every instruction’s operands have the right types, that branches target valid labels, that the stack is balanced at every join, and that indices into tables, functions and globals are in range.
What it does not check is behaviour. A module that validates can still loop forever, return wrong answers or read the wrong part of its own memory. Validation is the equivalent of a program compiling: necessary, and no evidence that it works.
That makes it a cheap gate rather than a test. It costs milliseconds, it catches a specific class of problem — usually introduced by a post-processing tool rather than by a compiler — and it belongs in every pipeline.
Running it
The basic invocation is one line and exits non-zero on failure, which is all a build script needs.
wasm-validate dist/engine.wasm && echo "valid"
Feature flags are where it gets interesting. By default wasm-validate accepts a particular set of
proposals, and a module using something outside that set is reported as invalid even though it would run
perfectly in a modern browser. Enable exactly the features your targets support:
wasm-validate \
--enable-simd \
--enable-bulk-memory \
--enable-reference-types \
--enable-multi-value \
dist/engine.wasm
Used that way the check becomes more useful than a plain validity test: it is a statement of which features you are willing to ship. A build that starts emitting an instruction from a proposal you did not enable fails here, which is exactly when you want to find out — rather than from a user on an older engine.
Choosing which features to allow
The feature list in the validator should mirror the oldest engine you support, and deciding that deliberately is worth half an hour once.
Bulk memory operations, reference types, multi-value returns and sign-extension operators are supported
everywhere current and are safe to enable. SIMD is supported broadly but not universally on older mobile,
which is why many projects ship a baseline build alongside — in which case the baseline build should be
validated without --enable-simd, so an accidental SIMD instruction in it fails the check.
Newer proposals — garbage collection, exception handling, tail calls, memory64 — have narrower support and should be enabled only if you have confirmed your targets handle them, and only for the build that needs them.
# the baseline build must not contain SIMD
wasm-validate --enable-bulk-memory --enable-reference-types dist/engine.baseline.wasm
# the enhanced build may
wasm-validate --enable-bulk-memory --enable-reference-types --enable-simd dist/engine.simd.wasm
Running two validations with different flags is how the check earns its place in a project that ships more than one build. Without it, nothing prevents the baseline build from acquiring an instruction that makes it fail on precisely the devices it exists to serve — and that failure appears only on those devices, which by definition are not the ones you test on first.
Reading the errors
The messages point at a byte offset and a specific rule, and they are precise once you know the shape.
dist/engine.wasm:0000118: error: type mismatch in i32.add, expected [i32, i32] but got [i32, f64]
dist/engine.wasm:0000204: error: invalid function index 42 (max 17)
dist/engine.wasm:00003a1: error: unexpected opcode 0xfd (SIMD not enabled)
dist/engine.wasm:0000000: error: unexpected magic value
Each maps to a different cause. A type mismatch in a compiler-produced module almost always means a
post-processing step corrupted it — wasm-opt with an unsupported feature, a hand-edit, or a tool that
rewrote a section without updating an index. An invalid index means the same. An unexpected opcode with a
feature name means the feature is used but not enabled in the validator. And an unexpected magic value
means the file is not a WebAssembly module at all — usually an HTML error page saved with a .wasm
extension by a failed download.
Convert the offset to a location with wasm-objdump when the message alone is not enough:
wasm-objdump -d dist/engine.wasm | grep -A4 -B4 '118:'
Checking imports and exports too
Validation says nothing about what a module asks for or provides, and both are part of the contract with whatever loads it. Two small checks close that gap.
# imports: nothing unexpected
wasm-objdump -x dist/engine.wasm | awk '/^Import\[/,/^Export\[/' \
| grep -oP '<\K[^>]+' | sort > /tmp/imports.txt
diff -u expected-imports.txt /tmp/imports.txt || { echo "unexpected imports"; exit 1; }
# exports: everything the loader needs
for sym in memory alloc dealloc process abi_version; do
wasm-objdump -x dist/engine.wasm | grep -q "\"$sym\"" \
|| { echo "missing export: $sym"; exit 1; }
done
The import check is the more valuable of the two. A dependency that pulls in WASI, or a build that
accidentally targets the wrong environment, changes the import list silently and fails at instantiation in
whichever environment does not provide those functions. Catching it at build time turns a production
LinkError into a failed pipeline.
Inspecting sections when something looks wrong
wasm-objdump -h lists the sections and their sizes, which is the fastest way to see what a module is made
of and where its bytes went.
wasm-objdump -h dist/engine.wasm
Sections:
Type start=0x0000000a end=0x00000041 (size=0x00000037) count: 9
Import start=0x00000043 end=0x00000082 (size=0x0000003f) count: 3
Function start=0x00000084 end=0x000000a1 (size=0x0000001d) count: 28
Memory start=0x000000a3 end=0x000000a8 (size=0x00000005) count: 1
Global start=0x000000aa end=0x000000c3 (size=0x00000019) count: 4
Export start=0x000000c5 end=0x00000121 (size=0x0000005c) count: 6
Code start=0x00000123 end=0x00004f12 (size=0x00004def) count: 28
Data start=0x00004f14 end=0x000061a2 (size=0x0000128e) count: 2
Custom start=0x000061a4 end=0x0000a3c1 (size=0x0000421d) "name"
Two things to look for. A large Custom section named name or .debug_* means debug information is
still present — strip it for release, which here would remove 16 kB of a 42 kB file. And a Data section
far larger than expected means embedded data you may not have intended, such as a lookup table generated
at compile time or a string table from a formatting library.
Wiring it into the build
The whole artifact check belongs in one script that the build calls and the pipeline runs.
#!/usr/bin/env bash
# scripts/check-artifact.sh
set -euo pipefail
WASM=${1:-dist/engine.wasm}
wasm-validate --enable-simd --enable-bulk-memory --enable-reference-types "$WASM"
wasm-objdump -x "$WASM" | awk '/^Import\[/,/^Export\[/' | grep -oP '<\K[^>]+' | sort > /tmp/imp.txt
diff -u expected-imports.txt /tmp/imp.txt
for sym in memory alloc dealloc process abi_version; do
wasm-objdump -x "$WASM" | grep -q "\"$sym\"" || { echo "missing export: $sym"; exit 1; }
done
echo "artifact ok: $(stat -c%s "$WASM") bytes, $(brotli -q 11 -c "$WASM" | wc -c) compressed"
Run it after every build, locally and in CI. It takes under two seconds and it is the only thing standing between a corrupted or mis-targeted artifact and your users.
Gotchas
- Validating without the feature flags you ship. Reports valid modules as invalid, or misses a feature you did not mean to use.
- Validating the pre-optimisation binary only.
wasm-optis a rewriting tool; validate its output. - An HTML error page saved as
.wasm. Reported as an unexpected magic value; check the download, not the compiler. - Missing export after an optimisation pass. Dead-code elimination removed a function nothing appeared to call; mark exports so they survive.
- Import list drifting silently. Without a committed allowlist, nobody notices until instantiation fails somewhere specific.
- Validating in CI but not locally. The failure arrives after a push instead of before one.
Performance note
Validating a 42 kB module takes about 4 ms and a 2 MB module about 90 ms. The import and export checks are
a few objdump invocations, under a second in total. The whole artifact script runs in under two seconds
for a typical module, which makes it one of the cheapest gates available and an easy one to justify
running on every build rather than only in the pipeline.
Frequently Asked Questions
Does the browser validate anyway?
Yes — every engine validates before compiling, and an invalid module throws a CompileError. The point of
validating at build time is to find that out before shipping rather than after.
What is the difference between wasm-validate and wasm-objdump?
The first answers whether the module is valid; the second shows you what is in it. They are complementary,
and the second is what you reach for once the first has told you something is wrong.
Should I validate a module I did not build? Always, before running it — along with checking its imports. For a third-party or user-supplied module, that check is part of the security boundary rather than a build convenience.
Related
- Catching size regressions in CI — the other half of the artifact job.
- How to decode .wasm files manually — reading the sections by hand.
- Loading untrusted plugins safely — the same checks as a security gate.
← Back to Testing & Verifying Wasm Builds