---
title: "End-to-end encryption"
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: "interfaces"
status: "Building for 1.2 · ships as a experimental API"
spec: "e2e"
canonical: "https://mainsdk.dev/docs/interfaces/e2e"
updated: "2026-10-28"
---

# End-to-end encryption

> 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 · ships as a experimental API · Canonical: https://mainsdk.dev/docs/interfaces/e2e · Updated: 2026-10-28

`e2e` encrypts messages and records on people's devices with keys only they hold, so your server stores ciphertext it cannot read. Groups use MLS. Reach for it for a private chat or notes.

> **Warning:** `e2e` is experimental and **not ready for real data**. The real MLS driver, `@mainsdk/e2e-mls`, implements RFC 9420 with ciphersuite 0x0001, but it reports `production: false` until an external security audit signs off, so `createE2e` and `mainsdk check` still refuse it in production (`MAIN-E2E-0009`). Build and test with it or with the `local` fake in `@mainsdk/testing`; do not protect real data yet. Import `e2e` from `mainsdk/experimental/e2e`, not from `mainsdk`.

## At a glance

- Status: Building for 1.2 · ships as a experimental API
- Phase: 3 (Mobile and regulated)
- Milestone: 1.2 Regulated industries
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/e2e.md
- Category: Security and privacy

Calls:

- `e2e.join()`
- `e2e.group.create({ members })`
- `e2e.group.add(groupId, member)`
- `e2e.group.remove(groupId, member)`
- `e2e.group.get(groupId)`
- `e2e.encrypt(groupId, data)`
- `e2e.decrypt(groupId, message)`
- `e2e.recovery.enrol(prf)`
- `e2e.recovery.restore(prf)`

In main.yaml:

```yaml
e2e:
  driver: mls
  suite: classic
  recovery: passkey
  fields:
    notes: [title, body]
  accept: []
```

Guarantees, whatever the driver:

- Private keys never leave the device
- The server stores and relays ciphertext only
- Adding or removing a member changes the epoch, and a removed member can't read anything new
- Group membership changes are signed and audited
- Recovery uses the passkey PRF extension, with nothing the server can read


## Drivers

Pick one in main.yaml with `e2e.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| MLS (RFC 9420) | `mls` | yes |  |
| Local fake (not for production) | `local` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-E2E-0007` | check | The post-quantum hybrid suite was asked for before spec post-quantum is in place (501) |
| `MAIN-E2E-0008` | check | A field in `e2e.fields` is read by server-side search, moderation or analytics, which skip it; `e2e.accept` silences it |
| `MAIN-E2E-0009` | check | The driver is not for production: `local` in production, `mls` without `@mainsdk/e2e-mls`, or a driver whose `production` is false in production (500) |
| `MAIN-E2E-0001` | runtime |  |
| `MAIN-E2E-0002` | runtime |  |
| `MAIN-E2E-0003` | runtime |  |
| `MAIN-E2E-0004` | runtime |  |
| `MAIN-E2E-0005` | runtime |  |
| `MAIN-E2E-0006` | runtime |  |
| `MAIN-E2E-0010` | runtime |  |
| `MAIN-E2E-0011` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Contract | 3 | 1.2 | `e2e#groups` |
| Sealed fields skipped by search | 3 | 1.2 | `e2e#skip` |
| Key recovery with passkeys | 3 | 1.2 | `e2e#recovery` |
| MLS groups for messages and records (RFC 9420 driver) | 3 | 1.2 | `e2e#mls-driver` |
| Post-quantum hybrid ciphersuite | 5 | 1.6 | `post-quantum#hybrid-kex` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
e2e:
  driver: mls          # @mainsdk/e2e-mls; local is the development fake
  suite: classic
  recovery: passkey
  fields:
    notes: [title, body]   # encrypted end to end: search, moderation and analytics skip these
```

```ts title="app/notes.ts"
import { configureE2e, e2e, toWire } from "mainsdk/experimental/e2e";
import { localE2eDriver, memoryE2eRelay } from "@mainsdk/testing";

const relay = memoryE2eRelay();   // the server side; in your app it is a few routes of your own server
await configureE2e({ self: "bob", driver: localE2eDriver, relay }).join();   // bob's device publishes its key package
configureE2e({ self: "alice", driver: localE2eDriver, relay });

const group = await e2e.group.create({ members: ["bob"] });   // keys stay on the devices
const sealed = await e2e.encrypt(group.id, "meet at six");     // the server stores ciphertext only
const note = toWire(sealed);                                   // a string that starts e2e1., safe in a column
```

## Configuration

| Key | What it does |
| --- | --- |
| `e2e.driver` | `mls` (`@mainsdk/e2e-mls`, RFC 9420) or `local`, the development fake |
| `e2e.suite` | `classic`. `pq-hybrid` is refused with `MAIN-E2E-0007` until the post-quantum spec is in place |
| `e2e.recovery` | `passkey`: recovery with the passkey PRF extension |
| `e2e.fields` | `model: [field]` for the fields you encrypt, so `check` can name the trade-offs |
| `e2e.accept` | `search`, `moderation` or `analytics`: trade-offs you accept, which silences `MAIN-E2E-0008` |

## What you get

- Private keys never leave the device.
- The server stores and relays ciphertext only.
- Adding or removing a member changes the epoch, and a removed member can't read anything new.
- Group membership changes are signed and audited.
- Recovery uses the passkey PRF extension, with nothing the server can read.

Adding or removing a member moves the group to a new epoch with fresh keys, and a removed member can't read anything new. A person who loses every device gets their keys back with their passkey.

## Calls

| Call | What it does |
| --- | --- |
| `e2e.join()` | Publishes this device's key package to the relay so others can add its member to a group. |
| `e2e.group.create({ members })` | Creates a group at epoch 0 for you and the listed members and returns it with its `id`. |
| `e2e.group.add(groupId, member)` | Adds a member and moves the group to a new epoch. The change is signed and audited. |
| `e2e.group.remove(groupId, member)` | Removes a member and moves to a new epoch the removed member can't read. |
| `e2e.group.get(groupId)` | Returns the group as this device sees it: its epoch and members. |
| `e2e.encrypt(groupId, data)` | Encrypts `data` for the group, on the device, and returns the sealed message to store. |
| `e2e.decrypt(groupId, message)` | Decrypts a sealed message, on a device that holds its epoch's keys. |
| `e2e.recovery.enrol(prf)` | Stores the device's keys, encrypted under a key from the passkey's PRF output, on the relay. |
| `e2e.recovery.restore(prf)` | On a new device, gets the keys back with the same passkey. |

## Examples

### Remove a member

```ts title="app/leave.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 });
const carol = createE2e({ self: "carol", driver: localE2eDriver, relay });
await bob.join();
await carol.join();

const group = await alice.group.create({ members: ["bob", "carol"] });
await alice.group.remove(group.id, "carol");          // epoch 1: carol gets nothing new
const sealed = await alice.encrypt(group.id, "carol is out");
await bob.decrypt(group.id, sealed);                   // bob reads it
await carol.decrypt(group.id, sealed).catch((e) => e.code);   // MAIN-E2E-0004
```

### Recover on a new phone

The app asks the passkey for its PRF output with WebAuthn and passes a function that returns it.

```ts title="app/recover.ts"
import { createE2e, type PrfSource } from "mainsdk/experimental/e2e";
import { localE2eDriver, memoryE2eRelay } from "@mainsdk/testing";

// In the browser: navigator.credentials.get({ publicKey: { ..., extensions: { prf: { eval: { first: salt } } } } })
declare const prf: PrfSource;

const relay = memoryE2eRelay();
const oldPhone = createE2e({ self: "bob", driver: localE2eDriver, relay });
await oldPhone.join();
await oldPhone.recovery.enrol(prf);                   // keys, encrypted under the passkey's PRF output

const newPhone = createE2e({ self: "bob", driver: localE2eDriver, relay });
await newPhone.recovery.restore(prf);                 // same passkey, same keys
```

## What the server can't do

A field encrypted end to end is a string that starts `e2e1.`. The server holds no key, so:

- **Search** does not index it.
- **Moderation** does not send it to a classifier. A recipient who holds the key files a report instead.
- **Analytics** does not record it as a prop.

`mainsdk check` names this trade-off for each field in `e2e.fields` that a search index, `moderate` or an analytics event would read (`MAIN-E2E-0008`). Keep the field out of that feature, or add the feature to `e2e.accept` once you accept it.

## Errors and checks

| Code | Meaning |
| --- | --- |
| `MAIN-E2E-0001` | `e2e` used before `configureE2e` |
| `MAIN-E2E-0002` | No such group on this device |
| `MAIN-E2E-0003` | Invalid request: a bad id, too many members, data over 4 MiB, a member with no key package |
| `MAIN-E2E-0004` | Can't decrypt: no key for that epoch (removed, or added later), changed, or another group |
| `MAIN-E2E-0005` | The passkey gave no PRF output |
| `MAIN-E2E-0006` | Nothing to recover with this passkey |
| `MAIN-E2E-0007` | Post-quantum hybrid suite asked for before it is available (also a check) |
| `MAIN-E2E-0008` | Check: an encrypted field is read by search, moderation or analytics |
| `MAIN-E2E-0009` | Check and runtime: the driver is not for production |
| `MAIN-E2E-0010` | A suite or feature the driver does not implement |
| `MAIN-E2E-0011` | You are not a member of the group |
| `MAIN-E2E-0012` | Two members committed for the same epoch and the retries are used up; try again |

## Swapping drivers

| Driver | Provider | |
| --- | --- | --- |
| `mls` | MLS (RFC 9420, suite 0x0001), `@mainsdk/e2e-mls` | the real driver; **not production until the audit signs off** |
| `local` | Group key ratchet on WebCrypto, `@mainsdk/testing` | development only; not MLS, not for real data |

## Testing

Use `localE2eDriver` and `memoryE2eRelay` from `@mainsdk/testing`. The relay records every byte it is given as `relay.seen`, so a test can prove no plaintext reached the server. No network is used.

## Your relay

Your relay must keep one global order (a `seq`) across a group's handshake messages. Members apply the first valid commit for an epoch in that order; a member whose commit lost applies the winner's and retries, up to three times, then fails with `MAIN-E2E-0012`. A relay that reorders handshakes can fork a group.

## Security and privacy

- The `mls` driver keeps each past epoch's secret so old messages decrypt (`retain`, default `all`). Inside a retained epoch there is no forward secrecy. Removed members are still locked out of later epochs.
- The `mls` driver keeps the plaintext of every record it sealed or opened in its state, so a record can be read again (MLS message keys work once). Set `keepRecords: false` to turn it off, and then a second `decrypt` of the same record fails. The cache sits in device state, protected only by the passkey recovery blob when exported, and it grows without bound. This is awaiting the owner's sign-off.
- After you restore state on a new device, the first seal, add or remove rotates the member's keys.
- The contract epoch is the MLS epoch minus one: a new group is epoch 0.
- The driver does not yet build external commits, PSK, ReInit, X.509 credentials, more than one device per member or key transparency; those refuse with `MAIN-E2E-0010`.
- The driver's state is exported and imported by the driver; your app provides the store.

- The `local` driver is not MLS: no TreeKEM, no credentials (key packages are trusted on first use) and no per-message forward secrecy inside an epoch. Never use it for real data.
- The server still sees who is in a group and when messages are sent.
- A removed member keeps what they already read; end-to-end encryption can't unsend it.
- Key handling for the rest of the app is described in [Keys](https://mainsdk.dev/docs/interfaces/keys), and general cryptography in [Crypto](https://mainsdk.dev/docs/interfaces/crypto).

## FAQ

**Can I use it in production?** Not yet. The MLS driver is built and tested against the official vectors, but it stays `production: false` until an external audit signs off, and `check` refuses both drivers in production. The gap is recorded as debt in spec `e2e`.

**Is it post-quantum safe?** No. A hybrid post-quantum suite comes only once the post-quantum spec is in place.

## Next

- [Encrypt end to end](https://mainsdk.dev/docs/guides/encrypt-end-to-end)
- [Keys](https://mainsdk.dev/docs/interfaces/keys)
- [Crypto](https://mainsdk.dev/docs/interfaces/crypto)
