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-validate and wasm-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.

Structure, not behaviour Validation checks section structure, instruction types, stack balance and index ranges. It cannot check that the module terminates, produces correct results or uses memory as intended. validation checks section structure and ordering operand types on every instruction stack balance at branches index ranges for calls and globals validation cannot check whether it terminates whether the answers are right whether memory access is intended that is what tests are for

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.

Three checks, seconds each Validation confirms the module is well-formed under the features you allow. An import allowlist confirms it asks for nothing unexpected. An export check confirms it provides everything the loader calls. wasm-validate well-formed, typed features you allow catches tool corruption import allowlist nothing unexpected a diff against a file catches the wrong target export check the loader's contract names, not just presence catches a stripped export

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.

What validation does and does not prove Validation checks that the binary is structurally well formed and type correct. It says nothing about whether the code is right, which is what the test suite is for. binary emitted by the build wasm-validate structure and types exit 0 any engine will load it exit 1 no engine will A validation failure almost always means a broken post-processing step, not a compiler bug. Run it on the final artifact — after wasm-opt and after any custom section stripping, not before. Validation passing tells you nothing about behaviour; it is a gate, not a test.

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-opt is 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.

← Back to Testing & Verifying Wasm Builds