End-to-end encrypted secret sharing with the Web Crypto API
I got tired of using onetimesecret.com so I built my own secret sharing
service. It has zero dependencies, only standard Web APIs, and the backend
never stores any plaintext because everything is end-to-end encrypted
(E2EE). The backend only sees the ciphertext and some metadata, and keeps
them for a limited amount of time in a Redis (or Valkey) configured to
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 password.
From Bob's point of view, the flow is: he types his secret, chooses a passphrase, and gets a link to share with Alice. He also needs to tell Alice the passphrase. Ideally, he uses a side channel for that. For 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 use it to encrypt the message intended for Alice. We cannot expect humans to generate 256-bit keys each time they want to share a one-time secret, it wouldn't be very practical. Passphrases are better suited for humans, but we need to "convert" them into cryptographic keys first. That's the job of a key derivation function (KDF) specifically designed for passwords. They turn a passphrase into a derived key of the right size and they are deliberately slow, burning a lot of CPU (or memory) on purpose to make brute-force attacks too expensive and (let's hope) infeasible.
Only the initial passphrase is shared with Alice on the side channel. She can deterministically recompute the key from it, and thus decrypt the secret. But since the KDF is deterministic, the same passphrase would produce the same key every time, which is not very good. That's why such KDFs also take a salt as input. The salt is not secret, but it should be random each time. And since it's not secret, it can safely be stored alongside the ciphertext in the service database. Alice recomputes the key with the passphrase and the salt.
Choice of algorithms
Since we don't use any third-party library, we can only choose from what the standard Web Crypto API offers. We need two things:
- A symmetric encryption algorithm, to encrypt the user's secret before it's sent to the backend
- A key derivation function, to turn the user-supplied passphrase into a strong cryptographic key
For the symmetric encryption, let's not get exotic and choose the boring AES-GCM with a 256-bit key. It encrypts the payload, but it also authenticates it. So if the backend tampers with the ciphertext, the recipient gets a decryption error instead of a silently wrong plaintext. AES-GCM is considered solid as of today.
AES-GCM also takes an initialization vector (IV) as input. Like the salt for the KDF, the IV is not secret and its job is to make the output different each time: without it, encrypting the same plaintext with the same key would always produce the same ciphertext. There is one hard rule though: never reuse an IV with the same key. A single reuse can leak the plaintexts and even let an attacker forge authenticated messages (a future blog post will go into details). Generating a random 12-byte IV 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 option to turn a human password into a key is Argon2, but it's still not available in the Web Crypto API at the time of writing. (Maybe it'll be eventually, though.) So we stick with PBKDF2-SHA256 with 600k iterations. The internal details don't matter much here, but like any password KDF it grinds the passphrase and the salt through 600,000 rounds of hashing, such that each brute-force guess costs CPU time, thus making brute-force attacks very hard.
PBKDF2 is less resistant to GPU brute force than Argon2, but I can accept that if it keeps the project at zero dependencies. These secrets are ephemeral anyway, with a lifetime of a few minutes.
Last ingredient: good quality random numbers. The Web Crypto API has that
covered too with crypto.getRandomValues().
Here are the three functions doing all the crypto. This JS code runs in the browser, on both the sender and the receiver side. In the code, the derived key is called a DEK, for data encryption key: the key that actually encrypts the payload given by Bob.
// Needed both sender- and receiver-side
async function deriveDekFromPassphrase(passphrase, salt) {
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(passphrase),
"PBKDF2",
false, // Not exportable
["deriveKey"],
);
const dek = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 600_000, // OWASP recommended count for PBKDF2-SHA256
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-GCM", length: 256 },
false, // Not exportable
["encrypt", "decrypt"],
);
return dek;
}
// Needed sender-side
async function encrypt(plaintext, passphrase) {
const salt = crypto.getRandomValues(new Uint8Array(16)); // Fresh random salt per secret
const iv = crypto.getRandomValues(new Uint8Array(12)); // 96-bit IV, standard size for GCM
const dek = await deriveDekFromPassphrase(passphrase, salt);
const ciphertext = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv },
dek,
new TextEncoder().encode(plaintext),
);
return {
ct: new Uint8Array(ciphertext).toBase64(),
iv: iv.toBase64(),
salt: salt.toBase64(),
};
}
// Needed receiver-side
async function decrypt(secret, passphrase) {
const { ct, iv, salt } = secret;
const dek = await deriveDekFromPassphrase(
passphrase,
Uint8Array.fromBase64(salt),
);
// Throws if the passphrase is wrong or the ciphertext was tampered with
const plaintext = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: Uint8Array.fromBase64(iv) },
dek,
Uint8Array.fromBase64(ct),
);
return new TextDecoder().decode(plaintext);
}
The rest is API calls and UI, and this article won't cover it since it's peripheral. But here is how secrets in transit are stored, with the TTL enforced by Redis/Valkey itself:
const pipe = redisClient.pipeline();
pipe.hset(secretKey, {
ct: input.ct,
iv: input.iv,
salt: input.salt,
created_at: createdAt,
expires_at: expiresAt,
alg: "pbkdf2-sha256-600000-aes-gcm-256",
});
pipe.expire(secretKey, ttlSecs);
await pipe.flush();
So the backend only ever sees:
- the ciphertext (
ct), - the IV for AES-GCM (not a secret),
- the salt for PBKDF2 (not a secret either).
Technically, Bob could handle the IV and the salt himself and send them to Alice alongside the passphrase, but it would be less practical for no real gain.
Once Alice gets the link and the passphrase, her browser:
- recomputes the cryptographic key (the DEK) from the passphrase and the salt,
- decrypts the ciphertext with the DEK and the IV,
- displays the secret.
And the backend can simply burn the message.
Some drawbacks
Alice has to get the passphrase right on the first try. Decryption happens client-side, so the backend cannot know whether she typed the right passphrase or not. It just hands over the ciphertext and its associated data on the first request, and burns everything right after.
Also, since the salt sits next to the ciphertext, a malicious backend (or anyone dumping the Redis/Valkey stored data) could run an offline brute force against the passphrase. PBKDF2 with 600k iterations makes it pretty hard, but the only real guarantee is how strong the passphrase Bob picks is.