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-packoremcc. - [ ] 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.
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.
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.
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 memoryis 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
- Implementing Argon2 password hashing in Wasm — parameters, memory and worker setup.
- Writing constant-time code for Wasm — masks, branch-free selection and what survives the compiler.
- Web Crypto vs Wasm for hashing — measured, with the cases each one wins.
- Verifying zero-knowledge proofs in the browser — pairing arithmetic and payload budgets.
- Sandboxing untrusted code with Wasm — limits, termination and per-tenant isolation.
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.
Related
- Browser Sandbox & Security Boundaries — what the platform guarantees.
- Plugin Systems & Extensibility — the same isolation, aimed at extensibility.
- Serverless & Edge Deployment — runtimes with fuel metering and hard limits.
← Back to Production Wasm: Workloads & Deployment