End-to-End Encryption

End-to-end encryption (E2EE) encrypts your call's audio and video on each participant's device, so Stream's servers forward media they cannot read.

Best Practices

  • Check EncryptionManager.isSupported() before offering E2EE in your app.
  • Attach the manager with call.setE2EEManager() before call.join().
  • Make sure every participant has the key before they publish or receive media.
  • Distribute keys over a channel you control. Never send key material through Stream.
  • Call dispose() on the manager when you are done with the call.

How it works

When E2EE is on, the SDK encrypts every outgoing media frame on the sender's device and decrypts it again on each receiver's device. Stream's infrastructure only ever sees ciphertext, so it can route your media but cannot watch or listen to it. You provide the encryption key (or keys); the SDK takes care of applying them to the media.

This builds on top of a regular call, so you should already have a client and a call (see Client & Authentication and Joining & Creating Calls).

Prerequisites

Encryption is a property of the call, so it has to be turned on when the call is created. The recommended way is to create a dedicated call type for encrypted calls - for example one named e2ee - and set its Encryption Mode in the Stream Dashboard:

  • Auto-on: every call of this type is encrypted. Use this for a call type that should always be encrypted.
  • Available: encryption is optional, and turned on per call at creation time.
  • Disabled: encryption is not allowed on the call.

With an auto-on call type, your app does not have to pass any encryption settings at all.

A call's encryption mode is fixed when the call is created and cannot be changed afterwards - there is no way to un-encrypt an existing call, or to encrypt a plain one. If you need both, use two call types.

Enabling E2EE on a call

Create an EncryptionManager, set a key, attach it to the call, and join. The manager must be attached before join(), because the call configures its connection for encryption as it joins.

import { EncryptionManager } from "@stream-io/video-client";

const call = client.call("e2ee", "my-call-id");

if (EncryptionManager.isSupported()) {
  const e2ee = await EncryptionManager.create(call.currentUserId);

  // a 16-byte key (see "Key management" below for how to produce one)
  e2ee.setSharedKey(0, key);
  call.setE2EEManager(e2ee); // must happen before join()

  // the e2ee call type is already encrypted, so no settings are needed here
  await call.getOrCreate();
  await call.join();
}

The manager you attach and the call have to agree. The SDK tells Stream that this device is joining encrypted whenever a manager is attached, and the join is rejected on a mismatch - for example attaching a manager to a disabled call, or joining an already-encrypted call without one.

If you cannot add a call type, you can set the mode when you create an individual call instead. Treat this as the exception - a dedicated call type keeps the setting in one place, out of your app code, and lets you change it without a release.

await call.getOrCreate({
  data: { settings_override: { encryption: { mode: "auto-on" } } },
});

By default keys are 16 bytes (AES-128). To use 32-byte keys (AES-256), pass the algorithm when creating the manager:

const e2ee = await EncryptionManager.create(call.currentUserId, {
  algorithm: "AES-256-GCM", // default is "AES-128-GCM"
});

Checking whether E2EE is active

The call state tells you whether encryption is really in effect, as confirmed by Stream when you joined - not just what you requested. Use it to render an indicator, such as a lock badge.

// the current value
console.log(call.state.e2eeEnabled);

// or observe it
const subscription = call.state.e2eeEnabled$.subscribe((enabled) => {
  lockBadge.hidden = !enabled;
});

This flag only becomes true once you have joined, so it is always false on a pre-join screen. Before joining, check the call's mode instead - auto-on tells you the call is going to be encrypted:

const willBeEncrypted = call.state.settings?.encryption?.mode === "auto-on";

Key management

The SDK runs inside each participant's app. Your app generates the keys and shares them with the other participants over a channel you control, and the SDK uses those keys to encrypt and decrypt the media locally. Stream's infrastructure only ever forwards the already-encrypted frames, so it never sees your keys or your media.

There are two ways to give participants keys. Use whichever fits your app.

Shared key

The simplest mode: everyone uses the same key. A common approach is to derive that key locally from a shared passphrase, so no key material ever travels over the network.

// derive a 16-byte AES key from a passphrase
async function deriveKeyFromPassphrase(
  passphrase: string,
): Promise<ArrayBuffer> {
  const enc = new TextEncoder();
  const baseKey = await crypto.subtle.importKey(
    "raw",
    enc.encode(passphrase),
    "PBKDF2",
    false,
    ["deriveBits"],
  );
  return crypto.subtle.deriveBits(
    {
      name: "PBKDF2",
      salt: enc.encode("your-app-salt"),
      iterations: 100_000,
      hash: "SHA-256",
    },
    baseKey,
    128, // use 256 for AES-256-GCM
  );
}

const key = await deriveKeyFromPassphrase("our-shared-secret");
e2ee.setSharedKey(0, key);

The first argument is the key index, used for rotation (see below).

Per-participant key

Instead of one shared key, each participant can have their own. You store your own key under your user id, and you store every other participant's key under their user id so their media can be decrypted.

// your own key
e2ee.setKey(call.currentUserId, 0, myKey);

// when a participant joins, set the key you received from them
call.on("call.session_participant_joined", async (event) => {
  const { id: userId } = event.participant.user;

  // fetch this participant's key over your own secure channel, then:
  const { key, keyIndex } = await fetchKeyForUser(userId);
  e2ee.setKey(userId, keyIndex, key);
});

// when a participant leaves, drop their keys
call.on("call.session_participant_left", (event) => {
  e2ee.removeAllKeys(event.participant.user.id);
});

With per-participant keys you have to get each participant's key to the others. Always do this over a secure channel that you control (for example your own backend over TLS). Never send raw key material through Stream.

Key index

Every key you set carries a key index - a number from 0 to 255 that you pass as the first argument to setSharedKey, or after the user id to setKey. Its job is to identify which key a piece of media was encrypted with: each encrypted frame is tagged with its key index, and on the receiving side the SDK looks up the key stored for that participant and index to decrypt it. Your outgoing media is always encrypted with the key you set most recently.

Because a participant can hold several keys at once (one per index), this is what makes rotation seamless: when you switch to a new key, media already in flight still decrypts with the previous key while new media uses the new one, so there is no gap where frames fail.

Key rotation

To rotate a key, set a new one under the next higher index and distribute it to everyone. Rotating when participants join or leave makes sure people only have access to media from while they were in the call.

const nextIndex = currentIndex + 1; // key index must stay between 0 and 255
e2ee.setSharedKey(nextIndex, newKey);

// once the old key is no longer needed for in-flight frames
e2ee.removeSharedKey(currentIndex);

The new key becomes the one your media is encrypted with straight away, while keys you set earlier stay available to decrypt frames that are still arriving. That is why you drop the old one only afterwards. With per-participant keys the same applies through setKey, and removeKey(userId, oldIndex) retires a single epoch. removeAllKeys(userId) drops everything you hold for a participant - use it when they leave.

removeSharedKey() takes the exact index to forget. Removing the key that is currently in use stops shared-key encryption until you set another one - it does not fall back to an older key.

Rotation is driven by your app - by membership changes, or by whatever policy you have.

Reacting to encryption events

Subscribe to events on the manager to keep your UI in sync and to react to problems. The on method returns a function you can call to unsubscribe.

EventWhat it meansWhat to do
e2ee.decryption_failedA participant's media could not be decrypted, usually a key mismatch.Check that participant has the right key; often clears after a rotation finishes.
e2ee.decryption_resumedDecryption recovered for a track that had been failing. Pairs one-to-one with e2ee.decryption_failed.Clear any "encryption problem" indicator you were showing.
e2ee.missing_keyA key that was needed is not held. Without a keyIndex it is your own key that is missing, so your outgoing media is not sent; with a keyIndex a participant's frame used a key you do not have, and it was dropped.Set or distribute the key with setKey / setSharedKey. The second case is normal while a key or rotation is still in flight.
e2ee.encryption_failedA key is set but a frame could not be encrypted, so your outgoing media is not sent.Check the reported reason; verify your key and that the codec is supported.
e2ee.decryption_stalledA participant's track has failed to decrypt on enough consecutive frames that it renders nothing. A key mismatch is the common cause, but a tampered or truncated frame looks the same from here.Surface an error and re-establish or rotate keys. e2ee.decryption_resumed clears it.
e2ee.unsupported_versionA participant is publishing a framing version this build cannot read, so their frames are dropped.Prompt the user to update this app - no key changes anything.

Each event names the userId it concerns, and the ones about a specific track also carry its trackType. That lets you report a peer's audio and video independently - a peer's video can recover while their audio is still failing.

const unsubscribe = e2ee.on(
  "e2ee.decryption_failed",
  ({ userId, trackType }) => {
    console.warn(`Could not decrypt ${trackType} from ${userId}`);
  },
);

For debugging you can also call e2ee.requestKeyState() (delivered via the e2ee.key_state event) to inspect which keys the SDK currently holds, and e2ee.enablePerformanceReporting(true) to receive e2ee.perf_report events with encrypt/decrypt throughput.

Putting it all together

A minimal end-to-end flow:

import { EncryptionManager, StreamVideoClient } from "@stream-io/video-client";

const client = new StreamVideoClient({ apiKey, user, tokenProvider });
const call = client.call("e2ee", "my-call-id");

let e2ee: EncryptionManager | undefined;

if (EncryptionManager.isSupported()) {
  e2ee = await EncryptionManager.create(call.currentUserId);
  e2ee.setSharedKey(0, await deriveKeyFromPassphrase("our-shared-secret"));

  e2ee.on("e2ee.decryption_failed", ({ userId, trackType }) => {
    console.warn(`Could not decrypt ${trackType} from ${userId}`);
  });

  call.setE2EEManager(e2ee); // before join()
}

await call.getOrCreate();
await call.join();

// when you are done
await call.leave();
e2ee?.dispose();

Limitations

  • Requires a supported browser; always gate your app on isSupported().
  • Server-side features that need to read the media (recording, transcription, HLS broadcasting) cannot work while media is encrypted.
  • You are responsible for generating, distributing, and rotating keys, and for removing keys when participants leave.