MainSDK
Search⌘K
How-to

Use end-to-end encryption in your app

Encrypt messages and records on people's devices with keys only they hold, using MLS for groups, so the server can't read them.

Building for 1.2

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

You will add e2e to your app, share one note with a group, remove a member and recover keys with a passkey. It takes about 15 minutes. It runs on the local development driver, which is a fake and not for real data. The real MLS driver, @mainsdk/e2e-mls, is built but its audit is pending, so it is not for production yet: use local to develop and test, and plan to switch to mls without changing your code.

You need

  • A MainSDK app with a main.yaml (see Thirty-minute path).
  • Two routes on your server that store and return opaque bytes: key packages, handshake mailboxes and a recovery blob. The mailbox must keep one global order per group. In tests, memoryE2eRelay() from @mainsdk/testing stands in.

1. Declare it

main.yaml
e2e:
  driver: local        # development only; mls (@mainsdk/e2e-mls) has the same contract, audit pending
  recovery: passkey
  fields:
    notes: [body]

2. Join and make a group

Each member's device publishes its key package once, then anyone can add that member.

app/notes.ts
import { createE2e } from "mainsdk/experimental/e2e";
import { localE2eDriver, memoryE2eRelay } from "@mainsdk/testing";

const relay = memoryE2eRelay();
const alice = createE2e({ self: "alice", driver: localE2eDriver, relay });
const bob = createE2e({ self: "bob", driver: localE2eDriver, relay });
await bob.join();
const group = await alice.group.create({ members: ["bob"] });

3. Encrypt and decrypt

app/seal-note.ts
import { type E2eApi, type E2eGroup, toWire } from "mainsdk/experimental/e2e";

declare const alice: E2eApi;   // the devices and the group from step 2
declare const bob: E2eApi;
declare const group: E2eGroup;

const note = toWire(await alice.encrypt(group.id, "meet at six"));   // store this string in your database
const text = new TextDecoder().decode(await bob.decrypt(group.id, note));

4. Remove a member

app/remove-member.ts
import type { E2eApi, E2eGroup } from "mainsdk/experimental/e2e";

declare const alice: E2eApi;
declare const group: E2eGroup;

await alice.group.remove(group.id, "bob");   // a new epoch: bob can't read what alice seals next

5. Recover keys

Ask the passkey for its PRF output with WebAuthn, pass it as a function, and enrol once per member. On a new device, restore with the same passkey.

Check it worked

Run npx mainsdk check. It names the trade-off for any encrypted field that search, moderation or analytics would read (MAIN-E2E-0008) and refuses driver: local in production (MAIN-E2E-0009).

Troubleshooting

  • MAIN-E2E-0003: a member has no key package; have them call e2e.join() first.
  • MAIN-E2E-0004: this device holds no key for the message's epoch, because the member was removed or added later.
  • MAIN-E2E-0012: two members committed for the same epoch; retry the call.
  • MAIN-E2E-0009: the driver is not for production. The MLS driver stays non-production until its external audit signs off, so do not ship end-to-end encryption for real data yet.

See the e2e interface.

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet