Writing Constant-Time Code for Wasm
This guide answers one task: write a routine whose execution time does not depend on the secret values it processes, in code that will be compiled to WebAssembly and then compiled again by a browser engine you do not control.
Prerequisites
- [ ] A routine that touches secret data — a key, a MAC, a password, a private scalar.
- [ ] Rust or C, and the ability to inspect the generated
.wat. - [ ]
wasm2watfrom the WebAssembly Binary Toolkit. - [ ] Realistic expectations: a browser is not a hardened environment, and this reduces risk rather than eliminating it.
What leaks, and how
A timing side channel exists whenever the work done depends on a secret. Three constructs create one, and they cover nearly every real case.
A branch on a secret takes different paths with different costs, and the engine’s branch predictor makes the difference measurable even when both paths contain the same number of instructions. An early exit is the same problem in its most common form: a comparison loop that stops at the first mismatching byte tells an attacker exactly how many leading bytes were correct, which reduces guessing a 32-byte MAC from infeasible to a few thousand attempts.
A memory access at a secret index leaks through the cache. Even though WebAssembly’s linear memory
is a flat array with no addresses exposed to the program, it is backed by real memory with real caches,
so whether a lookup hits or misses depends on which index was touched.
Build masks instead of branches
The fundamental technique is to turn a condition into an all-ones or all-zeros mask, then combine both candidate values arithmetically. No branch is taken, so no branch can be observed.
/// Returns 0xFFFF_FFFF when a == b, 0 otherwise. No branch on the values.
#[inline(always)]
fn ct_eq_u32(a: u32, b: u32) -> u32 {
let x = a ^ b; // 0 exactly when equal
let nz = (x | x.wrapping_neg()) >> 31; // 1 if x != 0, else 0
nz.wrapping_sub(1) // 0 → 0xFFFFFFFF, 1 → 0
}
/// Branch-free select: mask must be all ones or all zeros.
#[inline(always)]
fn ct_select(mask: u32, a: u32, b: u32) -> u32 {
(a & mask) | (b & !mask)
}
Both compile to a handful of WebAssembly instructions with no br_if on secret data. Verify that by
reading the output rather than assuming it, which the next section covers.
For byte comparison — MAC verification, token comparison — accumulate the whole length:
pub fn ct_bytes_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() { return false; } // length is public
let mut diff = 0u8;
for i in 0..a.len() { diff |= a[i] ^ b[i]; }
diff == 0
}
The length check branches, and that is fine: the length of a MAC is not secret. Being precise about which values are secret is half of getting this right.
Read the generated WebAssembly
The only way to know what the compiler produced is to look. wasm2wat turns the binary into readable
text, and a secret-dependent branch is visible as a br_if or if in the middle of what should be
straight-line arithmetic.
wasm2wat target/wasm32-unknown-unknown/release/crypto.wasm -o crypto.wat
# find the function and read it
sed -n '/func \$ct_bytes_eq/,/^ )/p' crypto.wat
(func $ct_bytes_eq (param i32 i32 i32) (result i32)
;; loop over length, accumulating with or/xor — no br_if on the compared bytes
(local $i i32) (local $diff i32)
...
(local.set $diff (i32.or (local.get $diff)
(i32.xor (i32.load8_u ...) (i32.load8_u ...))))
...)
What you are checking for is that the only control flow is the loop itself, whose trip count depends on
the public length. A conditional that tests a value derived from the inputs is the bug, and it appears
most often when someone “optimises” a mask routine back into an if.
What the toolchain can undo
Constant-time code is written against an adversarial compiler. LLVM is free to transform arithmetic back into a branch if it decides that is faster, and the browser engine compiles again with its own optimiser on top.
Several habits reduce the risk. Keep the mask operations in small #[inline(always)] functions so the
pattern stays recognisable rather than being spread across a large body where the optimiser sees more
context. Avoid if and the ternary operator entirely in secret-handling code, even where you believe the
result is branch-free. Where a language offers a barrier — Rust’s core::hint::black_box, a volatile
read in C — use it on values whose provenance you want the optimiser to forget.
And prefer a reviewed library over your own. The subtle crate in Rust exists precisely to encapsulate
these patterns with the compiler barriers already applied, and using it removes an entire category of
subtle regression as your code evolves.
Understand the limits, too. Engine tiering means the same function is interpreted, then baseline compiled, then optimised, with different timing at each stage. Speculative execution on the CPU is outside anyone’s control at this level. A browser tab shares a process with other page content. None of this makes the discipline pointless — it removes the easy, remotely measurable leaks — but it does mean a browser is the wrong place for a secret whose disclosure is catastrophic.
Testing for a data-dependent path
A statistical test will not prove constant time, but it reliably finds the obvious failures. Time the routine against inputs designed to take different paths and compare the distributions.
function timeMany(fn, input, n = 20000) {
const t = [];
for (let i = 0; i < n; i++) { const a = performance.now(); fn(input); t.push(performance.now() - a); }
t.sort((x, y) => x - y);
return { p50: t[n >> 1], p90: t[Math.floor(n * 0.9)] };
}
const allWrong = timeMany(verify, macDifferingAtByte0);
const nearlyRight = timeMany(verify, macDifferingAtLastByte);
console.log(allWrong, nearlyRight); // medians must be indistinguishable
An early-exit comparison shows this immediately: the near-match takes measurably longer because it compared more bytes. Timer coarsening in browsers hides small differences, so run the same test outside the browser under a standalone runtime where the clock is finer — the compiled code is the same, and the signal is much clearer.
Gotchas
- Using
==on secret bytes. Both JavaScript and Rust’s derivedPartialEqshort-circuit. Use an accumulating comparison. - Looking up a substitution table by a secret byte. Classic cache leak. Either scan the whole table with masks, or use a bitsliced implementation.
- Branching to handle a “special case” value. A zero scalar, an identity point, an empty input — each branch is a signal.
- Assuming
wasm-optpreserved your structure. It optimises aggressively. Inspect after it runs. - Timing tests inside the browser only. Clock coarsening hides real differences. Test under a standalone runtime as well.
- Writing your own primitive. Constant-time arithmetic for a full curve implementation is a research project. Use a reviewed library.
Performance note
Constant-time versions are slower by construction, and the factor depends on what you replaced. A branch-free byte comparison costs the same as a full-length scan — negligible for a 32-byte MAC. A masked table lookup over 256 entries replaces one load with 256, which for an inner loop is a 10–50× cost and is why real implementations bitslice instead. Budget for it: the security property is worth real cycles, and the routines that need it are usually not the ones dominating your runtime.
Frequently Asked Questions
Does WebAssembly make timing attacks harder or easier? Mostly neither. It has no instruction-level timing guarantees, and the engine adds layers you cannot inspect. What it does offer is a flat memory model without pointer arithmetic surprises, which makes branch-free code easier to write correctly.
Is crypto.subtle.timingSafeEqual available in browsers?
No, that is a Node API. In a browser, either compare inside your module with an accumulating comparison,
or compare HMACs of the two values, which makes the comparison’s timing independent of the inputs.
Should I disable optimisation for cryptographic code? No — unoptimised code is slower without being safer, and the engine optimises it again anyway. Inspect the output and use compiler barriers where you need them.
What counts as a secret for this purpose? Anything whose disclosure would matter: key material, plaintext, a password, a private scalar, and also intermediate values derived from them. Lengths, algorithm identifiers, public keys and error categories are generally not secret, which is why branching on them is acceptable and worth stating explicitly in a comment so the next reader does not “fix” it.
Can SIMD help constant-time code? Yes, particularly for masked table scans and bitsliced implementations: processing sixteen lanes at once reduces the cost of doing the same work for every possible index. The lane operations themselves have no data-dependent timing, so the property is preserved while the overhead falls substantially.
Related
- Implementing Argon2 password hashing in Wasm — a primitive where this discipline applies.
- Decoding Wasm opcodes for debugging — reading what the compiler actually emitted.
- Reading Binaryen IR from wasm-opt — checking the late-stage rewrites.
← Back to Cryptography & Untrusted Code