# 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 - not every device and build supports it.
- 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.
- Always call `dispose()` on the manager once you have left the call. On React Native this is mandatory - see [Disposing the manager](#disposing-the-manager).

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

On React Native the encryption itself runs natively, inside `@stream-io/react-native-webrtc`, rather than in JavaScript. The wire format is the same one the web and iOS SDKs use, so an encrypted call can mix React Native, browser and native participants without any extra work.

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-native/v2/guides/client-auth/) and [Joining & Creating Calls](https://getstream.io/video/docs/react-native/v2/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 join request tells Stream this device is joining encrypted and the connection is set up with encryption already in place.

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

const call = client.call("e2ee", "my-call-id");
const userId = call.currentUserId; // undefined until a user is connected
if (userId && EncryptionManager.isSupported()) {
  const e2ee = await EncryptionManager.create(userId);
  // 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.

`create()` never falls back to sending media in the clear - it throws instead, either because the installed `@stream-io/react-native-webrtc` is too old to support encryption, or because the device does not support it.

>
> **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).
>

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

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

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(userId, {
  algorithm: "AES-256-GCM", // default is "AES-128-GCM"
});
```

### Disposing the manager

The encryption manager is yours to own: the SDK never disposes it for you. On React Native there is no native detach, and closing the peer connections does not release the native encryption manager - `dispose()` is the only thing that does. Call it once you have left the call:

```ts
await call.leave();
e2ee.dispose(); // mandatory on React Native
```

Dispose only once the call is actually down. If `leave()` fails and media may still be live, keep the manager while you handle that failure - releasing it would pull encryption out from under peers that are still running. For the same reason, avoid disposing unconditionally in a `finally`.

`dispose()` is idempotent, so calling it more than once is safe. Key and media operations throw after disposal; event subscriptions stay callable.

>
> **Note:** **One `Call` instance is one call flow.** Keep one `Call` for the lobby, join, and active session. Attach its encryption manager before joining. After leaving, cancellation, or a failed join, use fresh instances for the next flow. The SDK handles reconnection within a live call using the existing instances.
>

If your own preparation is still running when the flow is cancelled, skip the abandoned join and release the late manager once teardown has succeeded.

### 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 the encryption setting Stream confirmed for the call when you joined - not just what you requested. It says nothing about whether every participant holds the right key or whether every track is decrypting; the [events above](#reacting-to-encryption-events) are what tell you that.

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

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

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

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

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.

Hermes has no Web Crypto, so use a native crypto library such as [`react-native-quick-crypto`](https://github.com/margelo/react-native-quick-crypto) to derive the key:

```ts
import { pbkdf2Sync } from "react-native-quick-crypto";

// derive a 16-byte AES key from a passphrase (use 32 bytes for AES-256-GCM)
const deriveKeyFromPassphrase = (passphrase: string): ArrayBuffer =>
  new Uint8Array(
    pbkdf2Sync(passphrase, "your-app-salt", 100_000, 16, "sha256"),
  ).slice().buffer;

e2ee.setSharedKey(0, deriveKeyFromPassphrase("our-shared-secret"));
```

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

The `.slice()` matters: `setSharedKey` takes an `ArrayBuffer` and validates its length, so a view onto a larger buffer would be rejected. Unlike the Web Crypto equivalent this is synchronous, with no promise to await.

>
> **Note:** The passphrase, salt, iteration count and key length are a contract between participants: everyone has to derive the key exactly the same way, or nothing decrypts. A different salt or iteration count silently produces a different key - the sender keeps encrypting happily, and only the other participants notice.
>

### 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);
  myKeyIndex = nextKeyIndex; // keep it, or the next rotation reuses this index
});
```

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

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

### 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 a **different 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.
- Indices are integers from `0` to `255`; `256` is rejected rather than wrapped. They do not have to increase - the most recently installed key wins whatever its index - but incrementing is the simplest way to be sure you are not reusing an index whose key is still needed. Coordinate reuse of an index only after its previous key has been retired everywhere.

If you hold both a key of your own (set with `setKey` under your user id) and a shared key, the per-user key wins: your outgoing media uses the most recently installed local key, and the shared key is only a fallback. Installing another participant's key never changes what you encrypt with.

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

Distribute the new key **before** you switch to it. The moment you call `setSharedKey`, your media goes out encrypted with it - anyone who has not received it yet cannot decrypt you until they do.

>
> **Note:** Removal takes the exact index to forget, and the two kinds behave differently. `removeSharedKey()` on the key currently in use stops shared-key encryption until you set another one - it does not fall back to an older shared key. `removeKey()` on your own latest per-user key falls back to the shared key, if you have one.
>

Removing another participant's keys is local only: it stops _you_ decrypting their old media, but it does not take away any key they already hold. To cut someone off, rotate to a fresh key and withhold it from them.

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                      | Meaning and response                                                                                                                                                                                                              |
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `e2ee.decryption_failed`   | A frame from a participant could not be decrypted and was dropped - usually a key mismatch. Throttled to about one per second while it keeps happening. Check that participant's key; it often clears once a rotation finishes.   |
| `e2ee.decryption_stalled`  | That participant's track has failed on enough consecutive frames to render nothing. A key mismatch is the common cause; a tampered or truncated frame looks the same. Surface an error and re-establish or rotate keys.           |
| `e2ee.decryption_resumed`  | Decryption recovered for a track that had been failing. Fires once per recovery, so it does not pair up one-for-one with the failures before it. Clear any "encryption problem" indicator.                                        |
| `e2ee.missing_key`         | Without a `keyIndex`, your own key is missing and your outgoing media is not sent. With one, a participant's frame used a key you do not hold and was dropped. Set or distribute the key; the second case is normal mid-rotation. |
| `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, your key, and that the codec is supported.                                                                        |
| `e2ee.unencrypted_frame`   | A frame arrived without encryption on an encrypted call. Investigate - a participant is misconfigured.                                                                                                                            |
| `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 change helps.                                                                  |
| `e2ee.key_state`           | A snapshot of the keys the SDK holds - fingerprints only, never key material. Debugging; request it with `requestKeyState()`.                                                                                                     |
| `e2ee.perf_report`         | Encrypt/decrypt throughput and timing, once a second while enabled. Debugging; opt in with `enablePerformanceReporting(true)`.                                                                                                    |

The media events name the `userId` they concern, and those about a specific track also carry its `trackType`, so you can report a peer's audio and video independently - their video can recover while their audio is still failing. `key_state` and `perf_report` are different: they carry a snapshot across all keys or tracks rather than a single user.

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

The key state reports fingerprints, never key material, so it is safe to log.

## Putting it all together

Two small helpers keep the manager tied to the call it belongs to, so the same code works for a regular call and for the ringing hooks below.

```ts
import {
  type Call,
  CallingState,
  EncryptionManager,
} from "@stream-io/video-react-native-sdk";

// `key` comes from your own key-management flow - see "Key management" above.
// Select it by `call.cid` if different calls use different keys.
export const attachE2EE = async (call: Call, key: ArrayBuffer) => {
  const userId = call.currentUserId;
  if (!userId) throw new Error("Connect a user before enabling E2EE.");

  const e2ee = await EncryptionManager.create(userId);
  try {
    e2ee.setSharedKey(0, key);
    call.setE2EEManager(e2ee); // must happen before join()
  } catch (err) {
    e2ee.dispose(); // nothing else holds it yet
    throw err;
  }
};

export const disposeE2EE = (call: Call) => {
  const e2ee = call.e2eeManager;
  if (e2ee instanceof EncryptionManager) e2ee.dispose();
};
```

`disposeE2EE` reads the manager back off the call rather than from a variable of your own, so it always releases _that_ call's manager even if another call has started since. Joining and leaving then look like this:

```ts
const joinEncryptedCall = async (call: Call) => {
  if (!EncryptionManager.isSupported()) {
    console.warn("E2EE is not supported on this device");
    return;
  }

  await attachE2EE(call, deriveKeyFromPassphrase("our-shared-secret"));

  // `call` is of an encrypted call type, so the mode needs no override here. If
  // you are overriding per call instead, pass `data.settings_override` to this
  // join too - with `create: true` it may be the call that gets created.
  await call.join({ create: true });
};

const leaveEncryptedCall = async (call: Call) => {
  if (call.state.callingState !== CallingState.LEFT) await call.leave();
  disposeE2EE(call);
};
```

Attaching immediately before `join()` is deliberate: the join request carries the encryption flag, and the connection is built with encryption already in place, so there is no moment in which media could go out unencrypted.

## Ringing calls

On a ringing call the SDK performs the join, not your code - the accept button joins internally, and an outgoing call joins once the callee answers - so there is no moment in which you hold the call and can still attach a manager. Register a pair of lifecycle hooks instead, and the SDK runs them for you on every ringing path: accepted from the CallKit or Telecom UI, accepted inside the app, and outgoing.

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

StreamVideoRN.setRingingCallLifecycleHooks({
  // `keyFor` is your own key lookup - the hook runs before any UI exists, so it
  // has to work from the `Call` alone
  onBeforeCallJoin: (call) => attachE2EE(call, keyFor(call)),
  onAfterCallLeave: (call) => disposeE2EE(call),
});
```

>
> **Warning:** Register the hooks at your application's entry point, next to `setPushConfig` and **outside** the React tree. A call accepted from a push notification can be created and joined before any component mounts - the app may be launched from a killed state to answer it - so hooks registered from inside React would be missed on exactly the path that most needs them.
>

The rules they follow:

- **Setup runs before the join.** `onBeforeCallJoin` is awaited inside `call.join()`, before any peer connection exists. Throwing aborts the join, and so does taking longer than five seconds; either way the call is not entered and the SDK ends the flow. Keep it fast - deriving a key locally is fine, a network round-trip is not.
- **Cleanup pairs with setup.** `onAfterCallLeave` runs once when the call is finished with, and receives the same `Call`. If setup is still running it waits for that to settle first, so it cannot free something the hook is about to create. With both hooks registered it only fires for a call setup actually ran for.
- **Concurrent triggers share one run.** A push acceptance racing an in-app tap produces one setup and one join. A trigger arriving after the call has joined is rejected instead, leaving the live call and its manager untouched.
- **A cancelled flow does not join.** A call left while setup is still running does not go on to join when that setup finishes.

Registering only `onAfterCallLeave` is supported too: with no setup hook to pair with, it is called for every ringing call that ends.

Cleanup is not awaited - the call ends whether or not your promise settles - so do not rely on it finishing before the OS suspends the app. On the push path it is the only cleanup that runs, since the call can end while the app is in the background where no React unmount fires.

### Switching between calls

Accepting a second ringing call leaves the active one; rejecting it leaves the active one running. Cleanup always uses the `Call` handed to the hook, so setup that finishes late for a call you have already left releases that call's manager and never touches the one you are now in.

### Creating an encrypted ringing call

The hooks attach the manager; they do not set the call's encryption mode. That is still fixed at creation, so an outgoing ringing call has to be created encrypted like any other - from a dedicated encrypted call type, or with the [per-call override](#overriding-the-mode-for-a-single-call):

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

Incoming ringing calls need nothing here: the call already exists, created by whoever placed it.

### Non-ringing calls

Regular calls are unaffected. You join those yourself, so you already have a moment before `join()` and the hooks do not run for them - attach and dispose as shown in [Putting it all together](#putting-it-all-together) above.

## Limitations

- Requires a device and build that support encryption; always gate your UI on `isSupported()`.
- Requires `@stream-io/react-native-webrtc` v145.4.1 or newer.
- Not available on React Native Web - `isSupported()` returns `false` there.
- 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.

---

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