With end-to-end encryption (E2EE) every audio and video frame is encrypted on the sender's device and decrypted on each receiver's. The media server forwards the frames but can't read them, and neither can anyone else without the key. Use it for telehealth, legal, or any call where "the provider can't see it" matters.
Before you start: your backend creates the room with e2ee: true, and
your app gets the key to every participant over its own channel —
Livqeno never sees it.
| Platform | Supported? |
|---|---|
| Web (Chrome, Edge, Safari 15.4+, Firefox 117+) | Yes |
| Flutter (iOS, Android, macOS, Windows, Linux) | Yes |
| Flutter Web | Not yet (e2eeNotSupported) |
| React Native | Not yet — joining an E2EE room fails with E2EE_NOT_SUPPORTED |
All platforms use the same frame format and key schedule, so a browser and a phone with the same key hear and see each other.
1. Create an encrypted room
E2EE is set when the room is created and can't be switched on later:
curl -X POST "$LIVQENO_API_URL/v1/rooms" \
-H "Authorization: Bearer $LIVQENO_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "name": "consult-4821", "e2ee": true }'Tokens minted for this room carry e2ee: true, and room.joined says so.
In an E2EE room the SDK refuses to publish unencrypted media: a client
without a key provider gets E2EE_REQUIRED from enableCamera() /
enableMicrophone(), before the camera is even opened.
Server-side features that need to see the media — recording, HLS, RTMP
restream, ingress, and transcription — are refused for an E2EE room with
E2EE_NOT_SUPPORTED.
2. Give the SDK a key
Pick one of two modes.
Shared passphrase — everyone in the room uses the same passphrase. The SDK turns it into a key on the device (PBKDF2-SHA256, 100,000 iterations, salted with the room id, so the same passphrase gives a different key in every room).
import { createRTCClient, createSharedKeyProvider } from '@livqeno/rtc';
const client = createRTCClient({
token,
endpoint,
e2ee: { keyProvider: createSharedKeyProvider(passphrase) },
});
const room = await client.join();
await room.enableCamera();
await room.enableMicrophone();With React, pass it to <LivqenoRoom> the same way:
const keyProvider = useMemo(() => createSharedKeyProvider(passphrase), [passphrase]);
<LivqenoRoom token={token} endpoint={endpoint} room={roomId} e2ee={{ keyProvider }}>final livqeno = Livqeno(
token: token,
endpoint: endpoint,
e2ee: LivqenoE2EEOptions(LivqenoKeyProvider.sharedPassphrase(passphrase)),
);
final room = await livqeno.join(roomId);
await room.enableCamera();
await room.enableMicrophone();Per-participant keys — your app generates a random key per participant (at least 16 bytes) and hands it out over its own secure channel. Each client sets its own key and the key of everyone it receives from:
import { createKeyProvider } from '@livqeno/rtc';
const keyProvider = createKeyProvider();
await keyProvider.setKey(myIdentity, myKey); // what I encrypt with
await keyProvider.setKey('bob', bobKey); // what I decrypt Bob with
const client = createRTCClient({ token, endpoint, e2ee: { keyProvider } });final keyProvider = LivqenoKeyProvider.perParticipant();
await keyProvider.setKey(myIdentity, myKey);
await keyProvider.setKey('bob', bobKey);
final livqeno = Livqeno(token: token, endpoint: endpoint, e2ee: LivqenoE2EEOptions(keyProvider));Keys can be set or changed at any time, including mid-call.
3. Rotate keys
When someone leaves, rotate so they can't decrypt what follows. Either set a new key under a new key index (0–255; the index travels with every frame, so receivers holding several keys pick the right one):
await keyProvider.setKey(myIdentity, newKey, 1);or ratchet the current key forward one step (HKDF-SHA256). Receivers
that fail to decrypt automatically try up to ratchetWindow (default 8)
ratchets ahead, so they follow without being told:
await keyProvider.ratchetKey(myIdentity); // shared mode: ratchetKey()await keyProvider.ratchetKey(participantId: myIdentity);4. Show encryption state
Each participant has an E2EE state:
| State | Meaning |
|---|---|
ok | Encrypting (you) / decrypting (them) normally. |
missing_key (missingKey) | No key for them at the frame's key index; their media is dropped. |
decrypt_failed (decryptFailed) | Frames don't decrypt even after ratcheting — usually a different passphrase. Their media is dropped. |
encrypt_failed (encryptFailed) | The device failed to encrypt; your frame was dropped. |
room.on('e2eeStateChanged', (participant, state) => {
if (state !== 'ok') showBadge(participant.identity, "Can't decrypt — check the passphrase");
});
room.isE2EE; // the room requires encryption
room.isE2EEActive; // this client is encryptingIn React: const state = useE2EEState(participant);
room.e2eeStateChanges.listen((update) {
if (update.state != LivqenoE2EEState.ok) showBadge(update.participant);
});Media is never sent in the clear by mistake: until a key is set, the
sender drops frames (missing_key) rather than sending plaintext.
Good to know
-
Audio redundancy (RED) is turned off while E2EE is configured, so encrypted audio has no packet-loss redundancy.
-
CSP: the web SDK runs encryption in a Web Worker created from a
blob:URL. If your Content-Security-Policy has noworker-src blob:, serve the worker yourself and pass it in:const worker = new Worker(new URL('@livqeno/rtc/e2ee-worker', import.meta.url)); createRTCClient({ token, endpoint, e2ee: { keyProvider, worker } }); -
Bundle size: the encryption code is only included when your app imports
createSharedKeyProviderorcreateKeyProvider(about 4 kB gzipped). -
isE2EESupported()tells you whether the current browser can encrypt. -
The media server still sees who is in the room, when they speak (audio levels travel in a header extension), and which video frames are keyframes. It never sees the content.
Errors
| Code | When |
|---|---|
E2EE_REQUIRED (e2eeRequired) | Publishing into an E2EE room without a key provider. |
E2EE_NOT_SUPPORTED (e2eeNotSupported) | The platform can't encrypt (React Native, Flutter Web, an old browser), or a server feature that needs plaintext was used on an E2EE room. |
For SDK implementers
The frame format and key schedule are specified in
docs/architecture/phase5-contracts.md §3, with byte-exact test vectors in
docs/architecture/e2ee-vectors.json. They match libwebrtc's frame
cryptor configured with ratchetSalt: "LivqenoRatchet",
keyDerivationAlgorithm: HKDF and no unencrypted magic bytes.