Cryptography & Untrusted Code

Two workloads share this topic because they share a property: the interesting risk is not performance but what the code can reach. Cryptographic routines need arithmetic the platform does not expose and timing behaviour that does not leak secrets. Untrusted code needs an execution environment where the worst case is bounded. WebAssembly is a credible answer to both, for the same underlying reason — a module has no ambient authority, no pointers outside its own memory, and no capability it was not handed.

Prerequisites

  • [ ] A Rust or C toolchain for building the primitives — wasm-pack or emcc.
  • [ ] Clarity about what the platform already provides through Web Crypto, because most of it does.
  • [ ] A worker, for anything that runs long enough to block a frame.
  • [ ] For untrusted code: a runtime with resource limits, or a worker you are willing to terminate.

Use Web Crypto first, and know why

The browser already implements AES, SHA-2, HMAC, ECDSA, ECDH, RSA and key derivation through SubtleCrypto. Those implementations are native, hardware-accelerated where the platform allows, constant-time where it matters, and reviewed far more thoroughly than anything you will compile. They should be the default for everything they cover.

const key = await crypto.subtle.importKey('raw', secret, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const mac = await crypto.subtle.sign('HMAC', key, data);

What Web Crypto does not cover is the reason this topic exists: memory-hard password hashing such as Argon2 and scrypt, modern primitives the platform has not adopted, elliptic curves outside the standard set, zero-knowledge proof verification, and anything where you need the intermediate values rather than just the result. For those, a compiled implementation is the only option in a browser.

What the platform gives you, and what it does not Standard hashes, symmetric encryption, signatures and key agreement are native and should be used directly. Memory-hard password hashing, newer curves and proof systems have no platform implementation and need a compiled module. use the platform SHA-256 · SHA-512 · HMAC AES-GCM · AES-CTR ECDSA · ECDH on P-256/384/521 PBKDF2 · random values native, reviewed, free compile it yourself Argon2id · scrypt · bcrypt Ed25519 where unavailable BLS · pairing curves · SNARK verifiers post-quantum candidates your review burden, your bugs Compiling a primitive the platform already has is a downgrade in every dimension — speed, size, and the number of eyes on the code.

Constant time is a property of the compiled output

Cryptographic code must not let its running time depend on secret data, because timing differences leak key material. In WebAssembly the discipline is the same as anywhere else, with one extra worry: you are several compilers removed from the machine code, and each layer may transform a carefully branchless routine back into something that branches.

The rules that survive compilation are the usual ones. No branch on secret values; select with arithmetic masks instead. No table index derived from a secret; cache behaviour is observable. No early exit from a comparison; compare all bytes and accumulate the difference.

// constant-time equality: no branch depends on the contents
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() { return false; }      // length is not secret
    let mut diff = 0u8;
    for i in 0..a.len() { diff |= a[i] ^ b[i]; }
    diff == 0
}

What the engine does underneath is genuinely outside your control — tiering, inlining and the CPU’s own speculation all matter, and the browser is not a hardened environment. That is an argument for keeping long-lived high-value secrets out of a tab entirely, not an argument for giving up on the discipline. The detail is in writing constant-time code for Wasm.

Memory-hard hashing belongs in a worker

Argon2id with realistic parameters deliberately spends 64 MB of memory and hundreds of milliseconds of CPU. That is the point — it is what makes offline attack expensive — and it is also completely incompatible with the main thread.

The pattern is a worker, a progress indication, and parameters chosen for the slowest device you support rather than the fastest. A login that takes 200 ms on a laptop and four seconds on an old phone is a product decision you should make deliberately, with measurements, not discover from a support queue.

Where the secrets live

A browser is a hostile place to keep a long-lived secret, and WebAssembly does not change that. Anything in linear memory is readable by any script that can reach the module’s instance; anything in the JavaScript heap is readable by any script on the page; a compromised dependency has both.

What you can do is bound the exposure. Derive short-lived keys rather than storing long-lived ones. Keep key material inside the module rather than round-tripping it through JavaScript, so it exists in one place. Zero buffers after use — it is not a guarantee, since the engine may have copied them, but it shortens the window. And where the platform offers non-extractable CryptoKey objects, use them: a key the JavaScript side cannot read is categorically better than one it politely does not.

Randomness, and where it must come from

Every cryptographic operation that generates a key, a nonce or a salt needs unpredictable bytes, and a WebAssembly module has no source of them. There is no instruction for randomness, no clock beyond what you import, and no entropy pool. Whatever the module uses must arrive from the host.

That makes randomness an import like any other, and the only correct source in a browser is crypto.getRandomValues:

const imports = {
  host: {
    random_bytes: (ptr, len) => {
      const view = new Uint8Array(memory.buffer, ptr, len);
      crypto.getRandomValues(view);              // cryptographically secure, seeded by the platform
    },
  },
};

Two failure modes recur. The first is a library that falls back to a deterministic pseudo-random generator when the import is missing — sometimes seeded from the current time — and continues without complaint. The result is keys and nonces an attacker can reproduce, and nothing in the application behaves differently. Always check what a compiled cryptographic library does when its randomness import is absent, and make the absence fatal rather than degraded.

The second is nonce reuse. Generating a fresh nonce per message is easy; reusing one because it was cached, or because a counter reset when the module was re-instantiated, breaks the confidentiality of every message under that key. If a nonce derives from a counter, the counter must live with the key and persist alongside it, not inside an instance that can be recreated.

WASI provides random_get for server-side builds, backed by the host’s entropy source, so the same module can obtain randomness on both sides of the network with one small shim per host.

Untrusted code: the import object is the policy

The second half of this topic inverts the perspective. Instead of protecting a secret inside a module, you are protecting everything outside it from a module you did not write.

A WebAssembly instance can do exactly two things: compute over its own memory, and call the functions you gave it. There is no fetch, no filesystem, no DOM, no way to construct a pointer into your heap. That makes the import object a literal capability list, and reviewing it is reviewing the entire attack surface.

const instance = await WebAssembly.instantiate(untrustedModule, {
  host: {
    log:      (ptr, len) => console.log('[plugin]', readUtf8(mem, ptr, len)),
    now_ms:   () => Math.floor(performance.now()),          // coarse by choice
    read_doc: (ptr, cap) => writeInto(mem, ptr, cap, currentDocumentText()),
  },
});

Three functions, three capabilities. No network, no storage, no access to other tenants’ data. Adding a fourth should feel like a decision, because it is one.

The only doors are the ones you build An untrusted instance computes inside its own linear memory and can reach the outside world only through the imported functions. Anything not imported is not merely forbidden — it does not exist from inside the module. untrusted instance its own linear memory no ambient authority cannot forge a pointer outward can still spin forever or exhaust memory log(ptr, len) now_ms() read_doc(ptr, cap) no fetch no storage no DOM Review the import object the way you would review a permissions manifest, because that is exactly what it is.

What the sandbox does not stop

Containment is not immunity, and being precise about the gap is what separates a real threat model from a comforting one.

A module can loop forever. There is no way to interrupt a running instance from the same thread, so the only browser answer is to run it in a worker you can terminate. Server runtimes offer fuel metering or epoch interruption, which pre-empt the module cleanly.

A module can exhaust memory. Instantiate with an explicit maximum so growth fails instead of consuming everything, and treat a failed memory.grow as a normal error path.

A module can read everything you put in its memory, including data from another tenant if you reused the instance. Per-tenant instances are cheap when the compiled module is shared — compilation is the expensive half — so there is rarely a good reason to pool.

And a module can produce wrong answers. The sandbox constrains what code can reach, not whether it is correct or honest. Output from an untrusted module is untrusted input to your application and needs the same validation as anything arriving over the network.

Reviewing a third-party module

Treat a .wasm file from outside as you would a binary dependency, because that is what it is. Its imports are inspectable before you run it, which is a genuine advantage over a JavaScript package.

wasm-objdump -x plugin.wasm | sed -n '/Import\[/,/Export\[/p'
# Import[3]:
#  - func[0] sig=0 <host.log>
#  - func[1] sig=1 <host.now_ms>
#  - func[2] sig=2 <env.emscripten_asm_const_int>     <- why does a plugin need this?

An unexpected import is a question that must be answered before the module runs. The Emscripten asm_const family, for instance, executes arbitrary JavaScript supplied by the module — providing it hands back everything the sandbox was giving you.

Size, review and the supply chain

A compiled cryptographic library is a binary dependency that ships to every user, and the usual questions apply with more force than for ordinary code.

Size first, because it is measurable. A minimal Argon2id build is around 15–30 kB compressed; a full-featured curve library with pairings is 400 kB to over a megabyte; a general-purpose cryptographic suite compiled without dead-code elimination can be several megabytes of routines you never call. Build with link-time optimisation, strip the debug sections, and check with twiggy what is actually in the binary — cryptographic libraries are notorious for pulling in formatting and error-message machinery that dwarfs the primitives.

Provenance second. A .wasm file on a CDN is exactly as trustworthy as whoever controls that CDN, and the browser has no subresource integrity for WebAssembly.instantiateStreaming in the way it does for scripts. Self-host the binary, pin it by content hash in the filename, and verify the hash at build time against the artifact your own pipeline produced from source you can read. Building from source in CI is strongly preferable to downloading a prebuilt binary, because a compiled cryptographic module is precisely the place where a supply-chain compromise is hardest to notice.

Review third. Nobody on a product team is going to audit an elliptic-curve implementation, and pretending otherwise helps nobody. The realistic bar is: use a widely deployed library with published test vectors, pin the version, run the vectors in CI, and subscribe to its security announcements. That is ordinary dependency hygiene, and it is what actually catches problems.

What the sandbox is actually for The boundary restricts what code can reach, not what it can compute. Confidentiality against a co-resident observer and timing side channels are outside what it promises. memory isolation the module cannot read the page's memory capability control it reaches only the imports you provide resource limits only if the host imposes fuel and memory caps timing side channels not addressed; constant-time code is still your job Treat the sandbox as containment for what code can touch, never as a guarantee about what it can infer. Every capability a module receives is one it can be made to misuse; hand over as few as the job needs.

Gotchas and failure modes

  • Compiling a primitive Web Crypto already provides. Slower, larger and less reviewed.
  • Branching on secret data. The most common constant-time mistake, and the compiler will not warn.
  • Argon2 parameters copied from a server tutorial. 256 MB and one second is fine on a server and catastrophic on a phone. Measure on real devices.
  • Reusing one instance across tenants. Residual data in linear memory is readable by the next caller. Create a fresh instance per tenant.
  • No memory maximum. An unbounded instance will take what it can when handed hostile input.
  • Trusting module output. Validate it exactly as you would a network response.

Verifying cryptographic code

Cryptographic correctness is verified against test vectors, not against your own expectations. Every serious primitive publishes them, and running them in CI is non-negotiable.

for (const v of ARGON2_VECTORS) {
  const out = await argon2id(v.password, v.salt, v.params);
  assert.equal(toHex(out), v.expected, `argon2id vector ${v.name}`);
}

Add a cross-implementation check where possible: compute the same value with a reference implementation outside the browser and compare. And for anything timing-sensitive, measure — a simple loop that times comparisons against matching and non-matching inputs will reveal a data-dependent branch, though absence of a signal in that test is weak evidence of safety rather than proof.

Threat models worth writing down

The single most useful artifact in this area is a short written statement of what you are defending against, because it turns arguments about mechanisms into questions with answers.

For client-side cryptography, the realistic model is narrow. You are defending against interception in transit, against a server operator who should not see plaintext, and against casual inspection of data at rest on the device. You are not defending against a compromised script on your own origin, an attacker with the unlocked device, or a malicious browser extension with host permissions — all three defeat anything a page can do, because they run in the same context as your code.

For untrusted code, the model is wider and the answers are stronger. You are defending against a module that reads data it was not given, reaches the network, tampers with the host application, or consumes resources without bound. WebAssembly plus an explicit import object plus a terminable worker plus a memory maximum covers all four, which is why this is the workload where the technology genuinely shines.

Writing those paragraphs down takes ten minutes and prevents the recurring conversation where someone proposes encrypting data in the browser with a key that is also in the browser. It also makes it obvious when a requirement cannot be met client-side at all, which is information a product team needs early rather than after a build.

Guides in this topic

Frequently Asked Questions

Is WebAssembly a security boundary? It is a memory-safety and capability boundary, enforced by the engine. It is not a defence against a module that computes something wrong, nor against side channels, nor against a module that simply refuses to terminate.

Can a Wasm module read my page’s variables? No. It has no reference to the JavaScript heap and cannot construct one. It sees only its own linear memory and the functions you imported — which is why an import that runs arbitrary JavaScript removes the whole guarantee.

Should I run untrusted code in an iframe as well? For anything genuinely hostile, yes: a cross-origin iframe adds process isolation on most platforms, which protects against classes of attack the language-level sandbox does not. Defence in depth is cheap here.

Does obfuscating my code as Wasm protect my algorithm? No more than minified JavaScript does. A .wasm file decompiles to readable form with standard tools, and anyone motivated enough to care will read it.

How do I handle a module that needs to be updated urgently? Treat a cryptographic module like a security-critical dependency: keep the build reproducible, keep the deployment path short, and make sure the cache key is the content hash so a new version reaches every user on their next visit rather than whenever an old entry expires.

Is it safe to log errors from a cryptographic routine? Log that an operation failed, never why in detail. Error messages that distinguish “bad padding” from “bad MAC” have historically been the entire basis of practical attacks, and a browser console is not a private place.

← Back to Production Wasm: Workloads & Deployment