Why We Chose NaCl Over WebCrypto
Why We Chose NaCl Over WebCrypto
Pick a cipher and library is one of the more consequential decisions in a privacy-first app. For Jottii, we use tweetnacl — a JavaScript port of NaCl — and specifically secretbox for symmetric authenticated encryption. We didn't use the browser's built-in WebCrypto API.
This is the post for engineers asking "why," and for users curious about the layer below "we use end-to-end encryption."
The constraint
Jottii is a single Expo (React Native) codebase that ships to web, iOS, and Android. The encryption layer needs to run identically on all three:
- The web build runs in a browser.
- The iOS and Android builds run on JavaScriptCore / Hermes, not in a browser.
That cross-platform rule is the first filter. It eliminates anything browser-only.
What WebCrypto offers
WebCrypto (window.crypto.subtle) is the W3C-blessed crypto API in browsers. It supports AES-GCM, AES-CBC, RSA, ECDSA, ECDH, HKDF, PBKDF2, and a few other primitives. It's well-implemented across modern browsers and runs natively, so it's fast — orders of magnitude faster than pure-JS crypto for large payloads.
For a web app, WebCrypto is the obvious default. It's present, it's audited, it's fast.
Why we didn't use it
Three reasons.
1. WebCrypto isn't on React Native
React Native doesn't ship WebCrypto. Hermes and JSC don't expose crypto.subtle. There are polyfills (e.g. react-native-webview-crypto, expo-crypto's subset), but they cover a fraction of the API and add platform-specific bridges. Maintaining a polyfill matrix across iOS, Android, and web — with subtle behavior differences — was more long-term cost than we wanted.
We could have shipped two crypto code paths (WebCrypto on web, something else on native) and unified at a higher abstraction. We tried this briefly. The complexity wasn't worth the speed win for our payload sizes.
2. The API surface
WebCrypto is genuinely powerful and genuinely awkward. Every operation is async, takes structured CryptoKey objects, and returns ArrayBuffers that need wrapping. For something like our use case — encrypt a JSON-serialized entry, decrypt later — the boilerplate is real.
NaCl, by contrast, is intentionally minimal. secretbox(message, nonce, key) and secretbox.open(box, nonce, key). Done. The API is small, hard to misuse, and the same on every platform.
For a small team, "hard to misuse" matters more than "fully featured." Famous crypto bugs almost always come from API misuse, not algorithm weakness.
3. The choice of cipher
WebCrypto's standard symmetric cipher is AES-GCM. NaCl's secretbox uses XSalsa20-Poly1305. Both are authenticated encryption with associated data (AEAD). Both are considered secure in 2026. But:
- AES-GCM has a hard nonce-reuse failure mode: re-using a nonce with the same key is catastrophic. The 96-bit nonce gives a non-trivial chance of collision after enough messages.
- XSalsa20-Poly1305 uses a 192-bit nonce, large enough that random nonces never collide in practice.
For a journaling app where every entry gets a fresh random nonce, both work. The wider nonce in XSalsa20 gives us a little more comfort and removes a class of "did we get the nonce derivation right" worries.
The library choice
Within the NaCl universe, there are several JS libraries. We use tweetnacl:
- It's an audited port of TweetNaCl, which itself is a small, well-reviewed reference implementation.
- It has zero dependencies.
- It works in browsers and React Native without any platform bridges.
- The API is the same as upstream NaCl, which means the body of crypto literature about "is NaCl secretbox safe" applies directly.
We pair it with tweetnacl-util for base64 encoding and @noble/hashes for the Argon2 / scrypt key derivation we use on passphrases.
What we gave up
Honesty section. Choosing NaCl over WebCrypto cost us:
Performance. Pure-JS crypto is slower than native. For Jottii's typical entry size (a few KB), the difference is imperceptible — encryption takes a few milliseconds. For very large entries or bulk operations (e.g. importing a thousand entries at once), it would matter; we'd reach for WebCrypto where available, conditionally, before optimizing.
Future flexibility. WebCrypto supports more primitives than NaCl (RSA, ECDSA, HKDF). If we ever need those — e.g. for a future sharing feature with key exchange — we'll have to reach beyond tweetnacl. NaCl has its own asymmetric primitives (box, sign), so the path exists, but it's not as broad a toolbox.
Standards alignment. WebCrypto is the W3C standard. Choosing NaCl is choosing a specific, well-understood library outside that standard. We accept the trade.
The architecture, briefly
The encryption layer in Jottii looks like:
- The user enters a passphrase. We derive a key with Argon2id (via
@noble/hashes). - The derived key encrypts a master key (32 random bytes from
tweetnacl.randomBytes). - The master key — never stored in plaintext, never transmitted — encrypts every entry with NaCl
secretbox. Each entry uses a fresh 24-byte random nonce. - The ciphertext + nonce go to Supabase. The server stores them. Cross-device sync exchanges ciphertext; decryption happens only on devices.
Recovery is a base64-encoded copy of the master key the user must save. Lose it, lose the data — that's the cost of the architecture.
For more on the architecture as a whole, see Inside Jottii — Cross-Device Sync Without Reading a Word and Zero-Knowledge Architecture, Without the Jargon.
Should you do the same?
Probably not, if your app is web-only. WebCrypto is the right default for browsers in 2026.
If you're building cross-platform with React Native or Expo and want a single, dependency-light crypto layer that's hard to misuse, NaCl/tweetnacl is a solid choice. The performance trade-off is real but small for typical app payloads. The simplicity dividend is large.
Boring crypto is the goal. We picked the boring option deliberately.