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.
  • [ ] wasm2wat from 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.

What to replace, and with what A branch on a secret becomes an arithmetic mask, an early exit becomes a full-length accumulation, and a lookup at a secret index becomes a scan over every entry with masked selection. if (secret_bit) { a() } else { b() } r = (a & mask) | (b & !mask) for i: if (a[i] != b[i]) return false for i: diff |= a[i] ^ b[i]; return diff == 0 v = table[secret_index] scan all entries, select with a mask Each replacement costs more instructions and always costs the same number of them — which is the entire point.

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.

Three optimisers between you and the CPU Source passes through the language compiler, an optional binary optimiser and the browser engine's own compiler. Each can transform branch-free arithmetic, which is why the generated code must be inspected rather than assumed. your source masks, no branches LLVM may reintroduce branches wasm-opt rewrites the binary too engine compiler tiers, speculates You can inspect the first three stages. The last one you cannot, which sets the honest ceiling on what this discipline achieves in a browser. Check the .wat after wasm-opt, not before — the binary optimiser runs late and is easy to forget.

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.

What leaks, and the replacement Any construct whose duration or memory access depends on a secret leaks it. Each has a branch-free equivalent that costs a few instructions. if on a secret select with a mask built from the condition secret-dependent index read every entry and combine with masks early-exit compare accumulate differences, compare once at the end division by a secret use a fixed modulus or Montgomery arithmetic The compiler may reintroduce a branch it thinks is equivalent; inspect the emitted Wasm, not the source. Bounds checks are uniform and do not leak, but a trap that only fires for some secrets certainly does.

Gotchas

  • Using == on secret bytes. Both JavaScript and Rust’s derived PartialEq short-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-opt preserved 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.

← Back to Cryptography & Untrusted Code