End-to-end encrypted secret sharing with the Web Crypto API

chmaynard1 pts0 comments

End-to-end encrypted secret sharing with the Web Crypto API

End-to-end encrypted secret sharing with the Web Crypto API

Published on 15 July 2026

I got tired of using onetimesecret.com so I built my own secret sharing<br>service. It has zero dependencies, only standard Web APIs, and the backend<br>never stores any plaintext because everything is end-to-end encrypted<br>(E2EE). The backend only sees the ciphertext and some metadata, and keeps<br>them for a limited amount of time in a Redis (or Valkey) configured to<br>never persist anything on disk (with save "" and appendonly no, and assuming no swap).

How to do E2EE secret sharing?

Let's say Bob wants to share a secret with Alice, for example a database<br>password.

From Bob's point of view, the flow is: he types his secret, chooses a<br>passphrase, and gets a link to share with Alice. He also needs to tell<br>Alice the passphrase. Ideally, he uses a side channel for that. For<br>example, the link goes on Slack and the passphrase on Signal.

Under the hood, Bob's Web browser has to generate a cryptographic key and<br>use it to encrypt the message intended for Alice. We cannot expect humans<br>to generate 256-bit keys each time they want to share a one-time secret,<br>it wouldn't be very practical. Passphrases are better suited for humans,<br>but we need to "convert" them into cryptographic keys first. That's the<br>job of a key derivation function (KDF ) specifically designed<br>for passwords. They turn a passphrase into a derived key of the right size<br>and they are deliberately slow, burning a lot of CPU (or memory) on<br>purpose to make brute-force attacks too expensive and (let's hope)<br>infeasible.

Only the initial passphrase is shared with Alice on the side channel. She<br>can deterministically recompute the key from it, and thus decrypt the<br>secret. But since the KDF is deterministic, the same passphrase would<br>produce the same key every time, which is not very good. That's why such<br>KDFs also take a salt as input. The salt is not secret, but it should be<br>random each time. And since it's not secret, it can safely be stored<br>alongside the ciphertext in the service database. Alice recomputes the key<br>with the passphrase and the salt.

Choice of algorithms

Since we don't use any third-party library, we can only choose from what<br>the standard Web Crypto API offers. We need two things:

A symmetric encryption algorithm, to encrypt the user's secret before<br>it's sent to the backend

A key derivation function, to turn the user-supplied passphrase into a<br>strong cryptographic key

For the symmetric encryption, let's not get exotic and choose the boring<br>AES-GCM with a 256-bit key . It encrypts the payload, but it also<br>authenticates it. So if the backend tampers with the ciphertext, the<br>recipient gets a decryption error instead of a silently wrong plaintext.<br>AES-GCM is considered solid as of today.

AES-GCM also takes an initialization vector (IV) as input. Like the salt<br>for the KDF, the IV is not secret and its job is to make the output<br>different each time: without it, encrypting the same plaintext with the<br>same key would always produce the same ciphertext. There is one hard rule<br>though: never reuse an IV with the same key. A single reuse can<br>leak the plaintexts and even let an attacker forge authenticated messages<br>(a future blog post will go into details). Generating a random 12-byte IV<br>for every encryption, like we do here, is a common way to stay safe.

For the key derivation, it's a bit more complicated. The best modern<br>option to turn a human password into a key is Argon2, but it's still not<br>available in the Web Crypto API at the time of writing. (Maybe it'll be eventually, though.) So we stick with PBKDF2-SHA256<br>with 600k iterations . The internal details don't matter much here,<br>but like any password KDF it grinds the passphrase and the salt through<br>600,000 rounds of hashing, such that each brute-force guess costs CPU<br>time, thus making brute-force attacks very hard.

PBKDF2 is less resistant to GPU brute force than Argon2, but I can accept<br>that if it keeps the project at zero dependencies. These secrets are<br>ephemeral anyway, with a lifetime of a few minutes.

Last ingredient: good quality random numbers. The Web Crypto API has that<br>covered too with crypto.getRandomValues().

Here are the three functions doing all the crypto. This JS code runs in<br>the browser, on both the sender and the receiver side. In the code, the<br>derived key is called a DEK, for data encryption key: the key that<br>actually encrypts the payload given by Bob.

// Needed both sender- and receiver-side<br>async function deriveDekFromPassphrase(passphrase, salt) {<br>const keyMaterial = await crypto.subtle.importKey(<br>"raw",<br>new TextEncoder().encode(passphrase),<br>"PBKDF2",<br>false, // Not exportable<br>["deriveKey"],<br>);<br>const dek = await crypto.subtle.deriveKey(<br>name: "PBKDF2",<br>salt: salt,<br>iterations: 600_000, // OWASP recommended count for PBKDF2-SHA256<br>hash: "SHA-256",<br>},<br>keyMaterial,<br>{ name: "AES-GCM", length: 256 },<br>false, // Not...

secret passphrase crypto time salt alice

Related Articles