Handling Secrets Inside Wasm Memory

This page answers one question that often comes up in security reviews: if a WebAssembly module handles a key, a token or a password, is that secret protected from the rest of the page? You want to understand what linear memory exposes, where copies of secrets end up, and practical rules for keeping secret exposure small.

Prerequisites

  • [ ] A module that handles secret values (encryption keys, session tokens, passwords).
  • [ ] Knowledge of who else runs code in the page (your code, third-party scripts, extensions).
  • [ ] For Rust, the zeroize crate; for C, an explicit-zeroing function.

Linear memory is not a vault

WebAssembly’s sandbox protects the host from the module: the module cannot read the page’s memory. The reverse is not true. Linear memory is a WebAssembly.Memory whose buffer is an ordinary ArrayBuffer; any JavaScript holding a reference to the memory or the instance’s exports can read every byte, including keys the module is holding. In a page, that means your own code, any third-party script that can reach the memory object, and — in the worst case — an XSS payload. Browser extensions with page access can do the same.

So a secret inside a Wasm module is about as exposed as a secret in a JavaScript variable in the same context. The module does not add confidentiality against code running alongside it. What you can control is how long secrets exist, how many copies there are, and which context holds them.

What the Wasm sandbox protects and what it does not The sandbox stops the module from reading the host page's memory, objects and APIs it was not given. It does not stop JavaScript in the same context from reading the module's linear memory, so secrets inside the module are visible to any script that can reach the memory object. protected host memory from the module APIs not imported other origins' data module is contained not protected linear memory from host JS secrets from same-page scripts copies left in freed memory minimise exposure

Step 1 — know where copies appear

A secret passed into a module is copied at least once: from a JavaScript Uint8Array or string into linear memory (by wasm-bindgen glue or your own set). Inside the module, a Vec that grows reallocates and leaves the old buffer’s contents in freed memory; a String built by formatting creates temporary copies; passing by value copies onto the shadow stack. Freed memory is not cleared — the allocator simply marks it free — so old copies remain readable until overwritten. And because linear memory never shrinks, those bytes stay in the ArrayBuffer for the instance’s lifetime.

Step 2 — zero secrets when done

In Rust, wrap secrets in types that zero themselves on drop:

use zeroize::{Zeroize, Zeroizing};

pub fn derive_and_use(password: &[u8], salt: &[u8]) -> Vec<u8> {
    let key: Zeroizing<[u8; 32]> = Zeroizing::new(argon2_derive(password, salt));   // zeroed on drop
    encrypt_with(&key)
}

#[wasm_bindgen]
pub fn unlock(mut password: Vec<u8>) -> Result<Session, JsError> {
    let session = open_vault(&password);
    password.zeroize();                                                             // clear the input copy
    session
}

zeroize uses volatile writes and compiler fences so the zeroing is not optimised away. Preallocate secret buffers at their final size so they never reallocate (reallocation leaves unzeroed copies behind). In C, use explicit_bzero or a volatile memset rather than memset, which compilers may remove.

Step 3 — shorten lifetimes

Hold secrets for as short a time as possible: derive a key, use it, zero it. For session-long secrets, keep them in one place, zero them on logout or lock, and consider re-deriving instead of caching where performance allows. Avoid logging, formatting or serialising secret values; derive a fingerprint (a short hash) when you need to refer to a key in logs.

The life of a password inside a module JavaScript encodes the password to bytes and passes it in, creating a copy in linear memory. The module derives a key into a preallocated buffer, uses it, and zeroes both the key and the password copy. JavaScript zeroes its own byte array. Only the derived session state, not the secret, remains. JS encodes password Uint8Array copy into Wasm glue or set() derive key preallocated buffer use, then zeroize key + input copy JS zeroes its array drop references

Step 4 — isolate secret handling in a worker

Running the code that handles secrets in a dedicated worker — with its own instance and memory — keeps those bytes out of the main page’s memory, where third-party scripts and most injected code run. The worker’s memory is not reachable from the page unless you share it. Terminating the worker discards its entire memory, which is the most thorough way to get rid of secrets. Pass only results (ciphertexts, signatures, session handles) back to the page.

Step 5 — prefer non-extractable WebCrypto keys for long-lived secrets

For keys that must live for a session or longer and use algorithms WebCrypto supports (AES-GCM, HMAC, ECDSA, ECDH, Ed25519 in recent browsers), import them as non-extractable CryptoKeys. The browser keeps the key material outside the JavaScript heap and outside linear memory, and no script — including yours — can read the raw bytes back. Wasm can still drive the protocol, calling WebCrypto through imports for the operations that use the key.

Threat models and honesty

None of this protects against code that runs in the same context while the secret is present: if an attacker controls JavaScript in your page, they can read inputs before they reach the module or call your functions. Zeroing and isolation reduce exposure to bugs, memory dumps, later-injected code and accidental logging; preventing script injection (CSP, dependency hygiene, Trusted Types) protects against the active attacker. Say so in security reviews rather than claiming Wasm makes secrets safe.

Secrets in server-side Wasm hosts

The same rules apply outside the browser, with a different set of observers. A Wasmtime or Node host can read every guest’s linear memory, and so can anything that captures the host process’s memory: core dumps, debuggers, crash reporters, memory-scraping malware. Hosts that run untrusted guests should be careful in the other direction too — never place host secrets in a guest’s memory, since the guest can read everything in its own linear memory. Pass secrets to guests only when necessary, prefer capability-style host functions that perform authenticated operations without revealing keys, and configure crash reporting so it does not upload guest memory snapshots. Instance-per-request designs help: a secret placed in a short-lived instance disappears when the instance is dropped, without relying on the guest to zero it.

Reviewing a module for secret handling

When auditing code that handles secrets, follow each secret from the point it enters the module to the point it is destroyed. List every place it is copied — function arguments by value, clones, formatting, serialisation, buffer growth — and check each copy is either avoided or zeroed. Search for Debug derives on types holding secrets (in Rust, a derived Debug prints the secret; implement it manually to redact), for error messages that include secret-derived values, and for caches that retain them. In C, look for secrets on the stack in functions that return early without clearing. A short checklist applied in code review catches most of these before they become habits.

Testing that zeroing happens

Zeroing is easy to break during refactors. A test can scan linear memory after an operation completes and assert that a known test secret’s bytes no longer appear anywhere — a crude but effective check that no forgotten copy remains.

Expected output

Keys are held in Zeroizing buffers preallocated to their final size; passwords are zeroed after derivation on both sides of the boundary; secret handling runs in a worker that is terminated on lock; long-lived keys are non-extractable WebCrypto keys used through imports; and logs contain only key fingerprints.

Gotchas

  • Believing linear memory is private. Host JavaScript can read it all.
  • Growing secret buffers. Reallocation leaves copies behind. Preallocate.
  • Plain memset for zeroing in C. Compilers may drop it. Use explicit zeroing functions.
  • Secrets in strings. Immutable and copied. Use byte arrays.
  • Formatting or logging secrets. Copies and leaks. Log fingerprints only.
  • Derived Debug on secret types. Logs print keys. Implement Debug manually to redact.

Performance note

Zeroing a 32-byte key costs nanoseconds; terminating and re-creating a secret-handling worker on lock costs a few milliseconds plus re-instantiation, a fair price for discarding its whole memory.

Cost of clearing secrets Approximate microseconds to zeroize a 32-byte key, zeroize a 1 MB buffer, and terminate and recreate a worker holding a 2 MB module from a cached compiled module. µs (approximate) zeroize 32-byte key 0.0 µs zeroize 1 MB buffer 60 µs terminate + recreate worker 8,000 µs

Frequently Asked Questions

Does memory growth leak secrets? Growth itself does not copy, but the old ArrayBuffer becomes detached; contents remain in the new buffer.

Can extensions read Wasm memory? Extensions with access to the page’s context can read anything that page’s scripts can.

Is SharedArrayBuffer worse? Shared memory is readable by every thread sharing it; keep secrets out of shared memories.

Should secrets be encrypted in memory? It only moves the problem to the key that decrypts them; focus on lifetime and isolation.

How can I check that no copy of a secret remains? After the operation, scan linear memory in a test for the known test secret’s bytes and assert they no longer appear.

Should a host place its own secrets in a guest’s memory? No — the guest can read all of its linear memory; use host functions that perform authenticated operations instead.

Do crash reports risk leaking secrets? Yes, if they include memory snapshots; configure reporters to exclude linear memory from uploads.

Does a short-lived instance help? Yes — dropping an instance after a request discards its memory, including any secrets the guest forgot to zero.

← Back to Browser Sandbox & Security Boundaries