# 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 UI.
- Attach the manager with `call.setE2EEManager()` **before** `call.join()`.
- Make sure every participant has the key before they publish or receive media.
- Rotate keys (with a higher key index) when people join or leave, and call `removeAllKeys()` for participants who leave.
- 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](https://getstream.io/video/docs/react/guides/client-auth/) and [Joining & Creating Calls](https://getstream.io/video/docs/react/guides/joining-and-creating-calls/)).

## Enabling E2EE on a call

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 to `auto-on` in the [Stream Dashboard](https://getstream.io/signin/?product=video). Every call of that type is then encrypted, and your app does not have to pass any encryption settings at all.

With that call type in place, each device creates an `EncryptionManager`, sets a key, attaches it to the call, and joins. The manager must be attached before `join()`, because the call configures its connection for encryption as it joins.

```ts
import { EncryptionManager } from "@stream-io/video-react-sdk";

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();
}
```

Notice that the snippet passes no encryption settings: the `e2ee` call type already carries the mode, so there is nothing to override.

<Admonition type="warning">

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 (or two calls).

</Admonition>

### Overriding the mode for a single call

If you cannot add a call type, you can set the mode when you create an individual call. 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.

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

### Encryption modes

The `mode` setting decides whether the call uses E2EE:

| Mode        | Meaning                                                                                             |
| ----------- | --------------------------------------------------------------------------------------------------- |
| `auto-on`   | Encryption is always on for the call.                                                               |
| `available` | Encryption is optional: the call can be encrypted, if settings are overriden at call creation time. |
| `disabled`  | Encryption is not allowed on the call.                                                              |

Encryption is never partial. Once a call is encrypted - whether that came from `auto-on` or from opting in under `available` - **every** participant sends encrypted media.
**There is no mode in which some people publish in the clear and others do not.**

Set the mode on the call type. Use `auto-on` for a call type whose calls should always be encrypted - the setup described above. Use `available` when only some calls of that type are encrypted, and turn it on per call at creation.

<Admonition type="info">

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.

</Admonition>

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

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

### Checking whether E2EE is active

Use the `useE2eeEnabled()` hook to render an indicator, such as a lock badge, once you are in the call. It reports whether encryption is really in effect for the call, as confirmed by Stream when you joined - not just what you requested.

```tsx
import { useCallStateHooks } from "@stream-io/video-react-sdk";

export const EncryptionBadge = () => {
  const { useE2eeEnabled } = useCallStateHooks();
  const e2eeEnabled = useE2eeEnabled();

  if (!e2eeEnabled) return null;
  return <span title="This call is end-to-end encrypted">🔒</span>;
};
```

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

```tsx
const { useCallSettings } = useCallStateHooks();
const settings = useCallSettings();
const willBeEncrypted = settings?.encryption?.mode === "auto-on";
```

This reads the mode Stream resolved for the call, so it accounts for both the call type and any per-call override. Once you are in the call, `useE2eeEnabled()` is the signal to trust.

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

<Mermaid>

```mermaid
flowchart LR
  subgraph A["📱 Participant A's app"]
    direction TB
    AKEYS["Your app code forwards keys to the SDK"]
    subgraph ASDK["🔒 Stream Video SDK"]
      AENC["Encrypt / decrypt<br/>media frames"]
    end
  end

  subgraph BACKEND["☁️ Your backend"]
    BACKKEYS["🔑 Securely generate<br>and exchange keys"]
  end

  subgraph NET["☁️ Stream infrastructure"]
    SFU["📡 SFU<br/>forwards ciphertext only<br/>never sees keys or media"]
  end

  subgraph B["📱 Participant B's app"]
    direction TB
    BKEYS["Your app code forwards keys to the SDK"]
    subgraph BSDK["🔒 Stream Video SDK"]
      BENC["Encrypt / decrypt<br/>media frames"]
    end
  end


  AKEYS -. exchange keys via own secure channel .- BACKKEYS
  BACKKEYS -. exchange keys via own secure channel .- BKEYS
  AKEYS -->|setKey / setSharedKey| AENC
  BKEYS -->|setKey / setSharedKey| BENC
  AENC <-->|encrypted media| SFU
  SFU <-->|encrypted media| BENC

  classDef appcode fill:#EAF2FF,stroke:#2563EB,stroke-width:1px,color:#1E3A8A;
  classDef sdk fill:#ECFDF5,stroke:#059669,stroke-width:1px,color:#065F46;
  classDef infra fill:#F3F4F6,stroke:#9CA3AF,stroke-width:1px,color:#374151;

  class AKEYS,BKEYS,BACKEND,BACKKEYS appcode;
  class AENC,BENC sdk;
  class SFU infra;

  style A fill:#F5F9FF,stroke:#2563EB,stroke-width:1px;
  style B fill:#F5F9FF,stroke:#2563EB,stroke-width:1px;
  style BACKEND fill:#F5F9FF,stroke:#2563EB,stroke-width:1px;
  style ASDK fill:#F3FEF9,stroke:#059669,stroke-width:1px;
  style BSDK fill:#F3FEF9,stroke:#059669,stroke-width:1px;
  style NET fill:#FAFAFA,stroke:#9CA3AF,stroke-width:1px;
```

</Mermaid>

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.

```ts
// 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.

Each key has an **index** (0-255). To rotate a key, set a new one under the same user id with a higher index and distribute it. Rotating your own key when someone leaves means they can no longer decrypt anything you publish afterwards.

```ts
// your own key - keep the index around so you can rotate it later
let { key: myKey, keyIndex: myKeyIndex } =
  await fetchAndDistributeKeyForUser(currentUserId);

// register our own key in our e2ee manager
e2ee.setKey(currentUserId, myKeyIndex, 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: theirKey, keyIndex } =
    await fetchAndDistributeKeyForUser(userId);

  // register their key in our e2ee manager
  e2ee.setKey(userId, keyIndex, theirKey);
});

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

  // rotate: bump the index, set a fresh key, and distribute it to the
  // remaining participants over your secure channel
  const { key: myNewKey, keyIndex: nextKeyIndex } =
    await fetchAndDistributeKeyForUser(currentUserId, myKeyIndex);

  // once the distribution to all other participants is finished,
  // switch the active key locally
  e2ee.setKey(currentUserId, nextKeyIndex, myNewKey);
});
```

The other side of rotation is receiving it: when a participant rotates their own key, they send you the new key and its index over your secure channel. Store it under their user id with `setKey`, and the SDK starts using it automatically as soon as their next frames arrive. Set it under the new index; keep the previous key in place for a moment so any in-flight frames still decrypt.

```ts
// your secure channel notifies you that a participant rotated their key
onRemoteKeyRotated(({ userId, keyIndex, key }) => {
  e2ee.setKey(userId, keyIndex, key);
});
```

<Admonition type="warning">

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.

</Admonition>

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

Use it like this:

- Start at index `0`.
- To rotate, set the new key under the **next higher index** and distribute it. The SDK immediately encrypts your outgoing media with it.
- Keep the previous key in place briefly, so frames still in flight (tagged with the old index) keep decrypting. Drop it afterwards with `removeSharedKey(oldIndex)` for a shared key, or `removeKey(userId, oldIndex)` for a participant's.
- The index must stay between 0 and 255. Increment it on each rotation.

### Key rotation

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

```ts
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 what makes the switch seamless, and why you drop the old one only afterwards.

<Admonition type="note">

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

</Admonition>

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.

| Event                      | What it means                                                                                                                                                                                                           | What to do                                                                                                                      |
| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `e2ee.decryption_failed`   | A 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_resumed`  | Decryption 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_key`         | A 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_failed`   | A 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_stalled`  | A 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_version` | A 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.

```ts
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 with the core client:

```ts
import {
  EncryptionManager,
  StreamVideoClient,
} from "@stream-io/video-react-sdk";

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

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

  call.setE2EEManager(e2ee); // assign the encryption manager
  await call.getOrCreate();
  await call.join();
}
```

The same flow inside a React component, creating the manager when the component mounts and cleaning up on unmount:

```tsx
import { useEffect, useState } from "react";
import {
  Call,
  EncryptionManager,
  StreamCall,
  StreamVideo,
  StreamVideoClient,
} from "@stream-io/video-react-sdk";

const passphrase = "our-shared-secret";

export const EncryptedCall = ({
  client,
  call,
}: {
  client: StreamVideoClient;
  call: Call;
}) => {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    if (!EncryptionManager.isSupported()) {
      console.warn("E2EE is not supported in this browser");
      return;
    }

    let e2ee: EncryptionManager | undefined;

    const setup = async () => {
      e2ee = await EncryptionManager.create(call.currentUserId);
      e2ee.setSharedKey(0, await deriveKeyFromPassphrase(passphrase));
      call.setE2EEManager(e2ee); // before join()

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

      await call.getOrCreate();
      await call.join();
      setReady(true);
    };

    setup().catch((err) => console.error("Failed to enable E2EE", err));

    return () => {
      call.leave().catch(() => {});
      e2ee?.dispose();
    };
  }, [call]);

  if (!ready) return null;

  return (
    <StreamVideo client={client}>
      <StreamCall call={call}>{/* your call UI */}</StreamCall>
    </StreamVideo>
  );
};
```

## Custom E2EEManager implementation

<Admonition type="warning">

This is an advanced topic. Most apps should use the built-in `EncryptionManager`. Only reach for a custom implementation if you need full control over the encryption scheme.

</Admonition>

`call.setE2EEManager()` accepts any object implementing the small `E2EEManager` interface, so you can plug in your own encryption scheme (for example SFrame) and own the entire crypto path. These two methods are the whole contract - the SDK calls them when it attaches media:

```ts
interface E2EEManager {
  encrypt(sender: RTCRtpSender, codec?: string, trackType?: string): void;
  decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: string): void;
}
```

`trackType` is an optional label the SDK passes for its own bookkeeping; a custom manager can ignore it.

You are free to attach your transform with either Encoded Transform API - `RTCRtpScriptTransform` or the older Insertable Streams (`createEncodedStreams`). The SDK prepares the peer connection for both, so there is nothing to declare.

A minimal skeleton that wires up your own transform:

```ts
import { type E2EEManager } from "@stream-io/video-react-sdk";

class MyEncryptionManager implements E2EEManager {
  encrypt(sender: RTCRtpSender, codec?: string, trackType?: string) {
    sender.transform = new RTCRtpScriptTransform(myWorker, {
      operation: "encode",
    });
  }

  decrypt(receiver: RTCRtpReceiver, userId: string, trackType?: string) {
    receiver.transform = new RTCRtpScriptTransform(myWorker, {
      operation: "decode",
      userId,
    });
  }
}

call.setE2EEManager(new MyEncryptionManager()); // before join()
await call.join();
```

With a custom manager you are responsible for all key management. The built-in key methods (`setKey`, `setSharedKey`, ...) and the `e2ee.*` events do not apply.

## Limitations

- Requires a supported browser; always gate your UI 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.


---

This page was last updated at 2026-08-14T17:47:10.966Z.

For the most recent version of this documentation, visit [https://getstream.io/video/docs/react/guides/end-to-end-encryption/](https://getstream.io/video/docs/react/guides/end-to-end-encryption/).