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();
}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()beforecall.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 and Joining & 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. 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.
Notice that the snippet passes no encryption settings: the e2ee call type already carries the mode, so there is nothing to override.
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.
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.
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:
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.
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:
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.
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.
// 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.
// 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.
// your secure channel notifies you that a participant rotated their key
onRemoteKeyRotated(({ userId, keyIndex, key }) => {
e2ee.setKey(userId, keyIndex, key);
});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 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, orremoveKey(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.
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.
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.
| 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.
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:
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:
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
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.
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:
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:
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.