Running libsodium in the Browser

This page answers one task: an application needs modern cryptography in the browser — end-to-end encrypted messages, signed documents, password-based key derivation — and you want a well-reviewed library rather than hand-rolled primitives. libsodium compiled to WebAssembly is the usual choice; you want to know which package to use, which APIs to rely on, and what to watch out for.

Prerequisites

  • [ ] A web application with a bundler, or a page that can load an npm package.
  • [ ] A clear threat model: what you protect, from whom, and where keys live.
  • [ ] Familiarity with the difference between encryption, authentication and key derivation.

What libsodium gives you

libsodium is a portable, audited C library offering a small set of high-level, hard-to-misuse primitives: authenticated encryption (crypto_secretbox, crypto_aead_xchacha20poly1305_ietf), public-key encryption (crypto_box, sealed boxes), signatures (crypto_sign, Ed25519), key exchange (crypto_kx), password hashing (crypto_pwhash, Argon2id), generic hashing (crypto_generichash, BLAKE2b) and secure random bytes. Each API picks safe algorithms and parameters, so callers choose what to do rather than how.

libsodium.js is the official Emscripten build, published to npm as libsodium-wrappers (high-level JavaScript API over the Wasm module) and libsodium-wrappers-sumo (with the full set of functions, including password hashing). In Node, sodium-native binds the native library and is faster; for code shared between browser and Node, the Wasm build runs in both.

libsodium packages and when to use them libsodium-wrappers is the standard Wasm build for browsers with common APIs. The sumo variant adds password hashing and less common functions at a larger size. sodium-native binds the native library in Node only. WebCrypto covers a subset natively without a library. package environment includes note libsodium-wrappers browser + Node secretbox, box, sign, kx, hashing standard build libsodium-wrappers-sumo browser + Node + crypto_pwhash (Argon2id), extras larger download sodium-native Node only full native libsodium fastest in Node WebCrypto (no library) browsers + Node AES-GCM, ECDH, Ed25519 (newer) no Argon2 or XChaCha

Step 1 — install and initialise

import _sodium from "libsodium-wrappers-sumo";

await _sodium.ready;                     // compiles and instantiates the Wasm module
const sodium = _sodium;

ready must resolve before any call. Initialise once at startup (or lazily before the first cryptographic operation) and reuse the object. The module is bundled as base64 inside the JavaScript in many versions, so no separate .wasm request is needed, but the bundle is correspondingly larger.

Step 2 — encrypt and decrypt with a symmetric key

const key = sodium.crypto_aead_xchacha20poly1305_ietf_keygen();
const nonce = sodium.randombytes_buf(sodium.crypto_aead_xchacha20poly1305_ietf_NPUBBYTES);
const ad = sodium.from_string("doc:42");                         // authenticated, not encrypted

const ct = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt(sodium.from_string("secret"), ad, null, nonce, key);
const pt = sodium.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ct, ad, nonce, key);   // throws if tampered

XChaCha20-Poly1305’s 24-byte nonce is large enough to generate randomly for every message without tracking counters, which removes the most common symmetric-encryption mistake (nonce reuse). Store the nonce alongside the ciphertext; it is not secret. Decryption throws if the ciphertext, nonce or associated data were modified.

Step 3 — sign and verify, exchange keys

const { publicKey, privateKey } = sodium.crypto_sign_keypair();
const sig = sodium.crypto_sign_detached(message, privateKey);
const ok = sodium.crypto_sign_verify_detached(sig, message, publicKey);

// Key exchange: both sides derive the same pair of session keys
const client = sodium.crypto_kx_keypair(), server = sodium.crypto_kx_keypair();
const { sharedRx, sharedTx } = sodium.crypto_kx_client_session_keys(client.publicKey, client.privateKey, server.publicKey);

Sealed boxes (crypto_box_seal) encrypt to a recipient’s public key without a sender key — useful for anonymous submissions or encrypting to a server key from the browser.

Encrypting a document to a recipient with libsodium The sender generates a random symmetric key, encrypts the document with XChaCha20-Poly1305 and a random nonce, seals the symmetric key to the recipient's public key, and stores ciphertext, nonce and sealed key together. The recipient opens the sealed key with their key pair and decrypts the document. random file key aead keygen encrypt document XChaCha20-Poly1305 seal file key to recipient public key store bundle ct + nonce + sealed key recipient decrypts open seal, then aead

Step 4 — derive keys from passwords

The sumo build includes Argon2id through crypto_pwhash, which turns a password into a key resistant to brute force:

const salt = sodium.randombytes_buf(sodium.crypto_pwhash_SALTBYTES);
const key = sodium.crypto_pwhash(
  32, password, salt,
  sodium.crypto_pwhash_OPSLIMIT_INTERACTIVE,
  sodium.crypto_pwhash_MEMLIMIT_INTERACTIVE,
  sodium.crypto_pwhash_ALG_ARGON2ID13,
);

The memory limit is allocated inside the Wasm module’s linear memory, so MEMLIMIT_MODERATE (256 MB) or higher may fail on phones or grow memory permanently. Tune parameters per device class and run derivation in a worker so it does not freeze the page; see deriving keys from passwords in Wasm.

Step 5 — handle secrets in memory carefully

Keys returned by libsodium.js are Uint8Arrays in the JavaScript heap (copied out of Wasm memory). JavaScript cannot guarantee that copies are erased — the garbage collector may move or retain data — so in-memory secrecy in a browser is best effort. Reduce exposure: keep keys as short-lived as possible, call sodium.memzero(key) when done (it zeroes that array, though not other copies), avoid converting keys to strings, and prefer non-extractable WebCrypto keys for long-lived secrets where WebCrypto supports the algorithm, since the browser keeps those outside the JavaScript heap.

When WebCrypto is enough

Current browsers support AES-GCM, HKDF, PBKDF2, ECDH and ECDSA in WebCrypto, and Ed25519/X25519 in recent versions. If those cover your needs, WebCrypto is native, faster and adds no download. libsodium earns its place when you need Argon2id, XChaCha20-Poly1305, sealed boxes, BLAKE2b, or a single API that behaves identically in browsers, Node and native apps that also use libsodium — interoperability with mobile or server code using the same library is a strong reason on its own.

Bundle size

The sumo build with its embedded Wasm is several hundred kilobytes before compression. Load it lazily, only on pages that need cryptography, and cache it long-term with a hashed filename. The standard build is smaller; use it unless you need password hashing or other sumo-only functions.

Encoding keys and ciphertexts for storage and transport

Keys, nonces, ciphertexts and signatures are binary, and most storage and transport layers prefer text. libsodium.js includes to_base64 and from_base64 (URL-safe variants by default) and to_hex/from_hex; use them consistently and record the variant, because mixing standard and URL-safe base64 between client and server is a common source of “invalid ciphertext” errors that look like cryptographic failures. Define one envelope format for everything you store — for example a small JSON object with a version number, algorithm identifier, nonce, ciphertext and, for sealed keys, the recipient’s key identifier — rather than concatenating raw bytes ad hoc. The version field matters more than it seems: when you later change parameters, rotate algorithms or add associated data, old envelopes must still be readable, and the version tells the decryptor which path to take.

Testing cryptographic code

Cryptographic code fails in ways ordinary tests miss: an encryption that “works” because the same object encrypts and decrypts, but uses a fixed nonce; a signature check that always passes because the result is ignored. Write tests that assert security properties, not just round trips: decrypting with the wrong key throws; flipping one bit of the ciphertext, nonce or associated data throws; two encryptions of the same plaintext produce different ciphertexts; a signature fails to verify against a modified message. Add interoperability tests with known test vectors or with another libsodium binding (the native library in a server test), which confirms that the browser produces data other parts of the system can read.

Keeping the library current

Security fixes reach the npm packages through new releases; pin versions in the lockfile, but watch for updates and apply them deliberately. Because the Wasm module is embedded in the JavaScript, updating the package is the whole update — there is no separate binary to remember.

Expected output

The app encrypts documents with XChaCha20-Poly1305 using random nonces, seals per-document keys to recipients’ public keys, signs exported manifests with Ed25519, derives the user’s key-encryption key with Argon2id in a worker, zeroes temporary keys after use, and loads libsodium lazily on the encryption screen only.

Gotchas

  • Calling functions before ready resolves. They are undefined or fail. Await initialisation.
  • Reusing nonces with fixed-nonce ciphers. Use XChaCha20 with random nonces.
  • High MEMLIMIT on phones. Allocation fails or memory stays grown. Tune per device.
  • Expecting secrets to vanish from memory. JavaScript copies may persist. Minimise lifetimes.
  • Shipping the sumo build everywhere. It is large. Load lazily, use the standard build when possible.
  • Round-trip-only tests. They miss fixed nonces and ignored verification results. Test tampering and wrong keys.

Performance note

On a mid-range laptop, XChaCha20-Poly1305 encryption ran at roughly 400 MB/s in the Wasm build; WebCrypto AES-GCM reached over 1.5 GB/s natively. Both are far faster than typical inputs require; Argon2id with interactive parameters took about 0.6 s.

Authenticated encryption throughput in the browser Megabytes per second for XChaCha20-Poly1305 in the libsodium Wasm build and AES-256-GCM in WebCrypto on a mid-range laptop. MB/s libsodium XChaCha20-Poly1305 (Wasm) 400 MB/s WebCrypto AES-256-GCM (native) 1,500 MB/s

Frequently Asked Questions

Is libsodium.js audited? libsodium itself has been audited; the JavaScript wrappers are thin. Keep the package updated.

Can I use it in a worker? Yes — import and await ready inside the worker.

Does it run in Deno and Bun? Yes, as an ordinary npm package with an embedded Wasm module.

Should I implement my own primitives in Rust instead? Only with strong reasons; audited libraries reduce risk far more than custom code.

Why does decryption fail after sending ciphertexts through a server? Often a base64 variant mismatch or a lost nonce; define one envelope format with explicit encoding and version.

How do I prove my encryption is not using a fixed nonce? Encrypt the same plaintext twice in a test and assert the ciphertexts differ; also assert tampered ciphertexts fail to decrypt.

Can libsodium keys be stored in IndexedDB? Yes, as bytes, but anything readable by page scripts is exposed to XSS; wrap them with a key derived from the user’s password or a non-extractable WebCrypto key.

← Back to Cryptography & Untrusted Code