---
title: "Use end-to-end encryption in your app"
description: "Encrypt messages and records on people's devices with keys only they hold, using MLS for groups, so the server can't read them."
section: "guides"
status: "Building for 1.2"
spec: "e2e"
canonical: "https://mainsdk.dev/docs/guides/encrypt-end-to-end"
updated: "2026-10-28"
---

# 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.

Status: Building for 1.2 · Canonical: https://mainsdk.dev/docs/guides/encrypt-end-to-end · 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](https://mainsdk.dev/docs/getting-started/thirty-minutes)).
- 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

```yaml title="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.

```ts title="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

```ts title="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

```ts title="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](https://mainsdk.dev/docs/interfaces/e2e).
