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.
End-to-end encryption is available from Flutter Video SDK 1.6.0.
Best practices
- Check
EncryptionManager.isSupportedbefore offering E2EE in your UI. It istrueon Android, iOS and macOS only. - 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 a new key before you switch to it locally. - Distribute keys over a channel you control. Never send key material through Stream.
- For calls that arrive through ringing, provide a key resolver. There is no screen to attach a manager from.
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 StreamVideo client and a Call (see Client & Authentication and Joining & Creating Calls).
Platform support
E2EE relies on native encoded-frame transforms, which are available on Android, iOS and macOS. On web, Windows and Linux EncryptionManager.isSupported is false and every other method throws UnsupportedError.
if (!EncryptionManager.isSupported) {
// Hide the encryption option, or fall back to an unencrypted call type.
}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 (see Call Types). Every call of that type is then encrypted, and your app does not have to pass any encryption settings at all.
final call = client.makeCall(callType: StreamCallType.fromString('e2ee'), id: 'my-call-id');
// the e2ee call type is already encrypted, so no settings are needed here
await call.getOrCreate();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. Trying to change it on an existing call is rejected by the server. 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(
encryption: const StreamEncryptionSettings(mode: StreamEncryptionMode.autoOn),
);Because the mode is fixed at creation, this only has an effect the first time - for a call that already exists, getOrCreate returns it as it is.
Encryption modes
The mode setting decides whether the call uses E2EE:
| Mode | Meaning |
|---|---|
StreamEncryptionMode.autoOn |
Encryption is always on for the call. |
StreamEncryptionMode.available |
Encryption is optional: the call can be encrypted, if settings are overridden at call creation time. |
StreamEncryptionMode.disabled |
Encryption is not allowed on the call. |
Encryption is never partial. Once a call is encrypted - whether that came from autoOn 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.
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.
Two ways to provide the key
The SDK needs an EncryptionManager holding the call's key before the call connects. There are two ways to get one there, and they cover different situations.
| Attach it yourself | Let the SDK resolve it | |
|---|---|---|
| You call | call.setE2EEManager() before join() |
Set encryptionKeyResolver in CallPreferences |
| Best for | Calls your own UI starts, such as a lobby screen | Calls the SDK joins for you, above all ringing |
| You control | The manager, its algorithm, and every key on it | Only the key; the SDK builds and owns the manager |
| Per-user keys | Yes | No, shared keys only |
An explicitly attached manager always wins, and the resolver is not called for that call. So you can set a resolver as a fallback for ringing and still attach a manager by hand wherever your app has a better answer.
1. Attach a manager yourself
Create the manager, set a key, attach it, then join. The manager must be attached before join(), because the call configures its connection for encryption as it joins - attaching afterwards throws a StateError.
import 'package:stream_video_flutter/stream_video_flutter.dart';
if (EncryptionManager.isSupported) {
final e2ee = EncryptionManager.create(userId: client.currentUser.id);
// a 16-byte key (see "Key management" below for how to produce one)
await e2ee.setSharedKey(0, key);
await call.setE2EEManager(e2ee); // must happen before join()
await call.join();
}EncryptionManager.create is synchronous, but every method that touches keys returns a Future - await them before joining.
By default keys are 16 bytes (AES-128). To use 32-byte keys (AES-256), pass the algorithm when creating the manager:
final e2ee = EncryptionManager.create(
userId: client.currentUser.id,
algorithm: EncryptionAlgorithm.aes256Gcm, // default is aes128Gcm
);Every participant must use the same algorithm and a matching key length, or frames will not decode.
One manager belongs to one call, both ways round. Attaching a manager that is already attached to another call throws, and so does attaching a second manager to a call that already has one - keys live on the manager, so sharing one between calls makes them overwrite each other. Managers are not reusable: leaving a call releases and disposes its manager, so give the next call its own. To avoid deriving a key twice, keep the derived bytes and import them again rather than keeping the manager.
The manager is disposed for you when the call ends. If you abandon a call you attached one to without ever joining - backing out of a lobby screen, say - release it yourself:
@override
void dispose() {
if (!_joining) unawaited(call.clearE2EEManager());
super.dispose();
}2. Let the SDK resolve the key
Set a resolver and the SDK builds the manager, imports the key and attaches it before the call connects. This exists for the calls your UI does not start - see Ringing calls below.
Future<CallEncryptionKey?> resolveKey(CallEncryptionKeyRequest request) async {
// Answer only for the calls you mean to encrypt. The request carries the
// call's id and nothing else, so this decision is yours to make from
// whatever your app already knows about the call.
final key = await myKeyStore.keyForCall(request.callCid);
if (key == null) return null;
return CallEncryptionKey.shared(bytes: key, keyIndex: 0);
}The resolver rides CallPreferences, so you can scope it as widely or as narrowly as you like:
// Every call in the app.
StreamVideo(
apiKey,
user: user,
options: StreamVideoOptions(
defaultCallPreferences: DefaultCallPreferences(
encryptionKeyResolver: resolveKey,
),
),
);
// Or only calls answered from a notification.
streamVideo.observeCoreRingingEvents(
acceptCallPreferences: DefaultCallPreferences(
encryptionKeyResolver: resolveKey,
),
onCallAccepted: (call) { /* navigate to your call screen */ },
);
// Or one specific call.
final call = client.makeCall(
callType: callType,
id: callId,
preferences: DefaultCallPreferences(encryptionKeyResolver: resolveKey),
);Some details worth knowing:
- Resolved once per call, before the first join attempt. Reconnects reuse the manager they already have - re-importing at a key index that is in use would break decryption of frames still in flight.
- Returning
nulljoins unencrypted, exactly as if no resolver were set. That is the right answer for a call you do not mean to encrypt; for one that requires encryption, the join is rejected by Stream as described above. - Throwing fails the join. Encryption is never quietly dropped because a key lookup failed.
- The request carries the call's id and nothing else. The SDK does not tell you the call's encryption mode: it does not reliably know it before the join, and no other Stream SDK offers one. Decide from what your app knows, and let the mismatch rule above catch a wrong answer.
- Shared keys only. Per-user keys need a key for every remote participant, and participants keep arriving after the join, so a resolver called once beforehand cannot cover them. Use
call.e2eeManagerfor those, as described under Per-participant key.
Keep the resolver quick. It runs inside the join, and on platforms where answering a call holds a system watchdog open a slow key fetch can get the join timed out.
Ringing calls
Ringing is the case the resolver exists for. A user answering an incoming call goes straight from the notification to the call, with no lobby screen in between to attach a manager from.
Set the resolver on defaultCallPreferences and every path is covered:
StreamVideo(
apiKey,
user: user,
options: StreamVideoOptions(
defaultCallPreferences: DefaultCallPreferences(
encryptionKeyResolver: resolveKey,
),
),
);Checking whether E2EE is active
Once you are in the call, CallState.isE2eeEnabled reports 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.
PartialCallStateBuilder(
call: call,
selector: (state) => state.isE2eeEnabled,
builder: (context, isEncrypted) => isEncrypted
? const Icon(Icons.shield_rounded, color: Colors.green)
: const SizedBox.shrink(),
);Or read it directly outside a widget:
final isEncrypted = call.state.value.isE2eeEnabled;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 - autoOn tells you the call is going to be encrypted:
final willBeEncrypted =
call.state.value.settings.encryption.mode == StreamEncryptionMode.autoOn;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, isE2eeEnabled 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 kinds of key you can give participants. Use whichever fits your app.
Key changes take effect on the frames encrypted after they are applied. You can call setSharedKey or setKey during a call to fix a wrong key (same index) or to rotate (next index) - there is no need to leave and rejoin.
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.
The SDK takes raw bytes and does not care where they came from, so derivation lives in your app. This example uses the cryptography package:
import 'dart:convert';
import 'dart:typed_data';
import 'package:cryptography/cryptography.dart';
Future<Uint8List> deriveKeyFromPassphrase(String passphrase, String salt) async {
final pbkdf2 = Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: 100000,
bits: 128, // use 256 for aes256Gcm
);
final derived = await pbkdf2.deriveKey(
secretKey: SecretKey(utf8.encode(passphrase)),
nonce: utf8.encode(salt),
);
return Uint8List.fromList(await derived.extractBytes());
}
// salt per call/room, not one app-wide value
final key = await deriveKeyFromPassphrase('our-shared-secret', call.callCid.value);
await e2ee.setSharedKey(0, key);Derive the salt per call or room, as above: a static app-wide salt lets one precomputation cover every room in your app.
The salt and the iteration count are a contract between participants, not between SDKs: change either and peers on the old build derive a different key from the same passphrase, and nothing decrypts.
The first argument to setSharedKey is the key index, used for rotation (see below).
PBKDF2 with 100,000 iterations is deliberately expensive. Derive the key once before joining rather than per track, and consider Isolate.run if you need the frame budget on the calling isolate.
A passphrase-derived key is a demo-friendly way to get every device onto the same material. Real integrations generate and distribute keys out of band, over a channel they control. Stream never transports them.
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, distribute the new key, then set it under the same user id with a higher index. 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
var (myKey, myKeyIndex) = await fetchAndDistributeKeyForUser(currentUserId);
// register our own key in our e2ee manager
await e2ee.setKey(currentUserId, myKeyIndex, myKey);
// when a participant joins, set the key you received from them
call.callEvents.on<StreamCallSessionParticipantJoinedEvent>((event) async {
final userId = event.user.id;
// fetch this participant's key over your own secure channel, then:
final (theirKey, keyIndex) = await fetchAndDistributeKeyForUser(userId);
// register their key in our e2ee manager
await e2ee.setKey(userId, keyIndex, theirKey);
});
// when a participant leaves, drop their keys and rotate your own
call.callEvents.on<StreamCallSessionParticipantLeftEvent>((event) async {
await e2ee.removeAllKeys(event.user.id);
// rotate: bump the index, set a fresh key, and distribute it to the
// remaining participants over your secure channel
final (myNewKey, nextKeyIndex) =
await fetchAndDistributeKeyForUser(currentUserId, myKeyIndex);
// once the distribution to all other participants is finished,
// switch the active key locally
await e2ee.setKey(currentUserId, nextKeyIndex, myNewKey);
(myKey, myKeyIndex) = (myNewKey, nextKeyIndex);
});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) async {
await 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.
Reach the manager through call.e2eeManager when you did not keep a reference to it:
final e2ee = call.e2eeManager;
if (e2ee != null) await e2ee.setKey(userId, keyIndex, key);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.
That grace period works in one direction only. It covers frames that were already sent under the old index; it does nothing for a participant who does not have the new key yet. Your very next frame is tagged with the new index, so anyone still missing that key reports missingKey and drops your media until it arrives. Distribute first, switch afterwards.
Use it like this:
- Start at index
0. - To rotate, distribute the new key first, then set it under the next higher index. The SDK encrypts your outgoing media with it from the next frame on, so setting it before your peers have it costs them that stretch of media.
- 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, distribute the new key to everyone under a higher key index, and set it locally once they have it. Rotating when participants join or leave makes sure people only have access to media from while they were in the call.
final nextIndex = currentIndex + 1; // key index must stay between 0 and 255
// first: get the new key to every participant over your own secure channel
await distributeKeyToParticipants(newKey, nextIndex);
// then: switch your own outgoing media over to it
await e2ee.setSharedKey(nextIndex, newKey);
// once the old key is no longer needed for in-flight frames
await e2ee.removeSharedKey(currentIndex);The order matters. The new key becomes the one your media is encrypted with straight away, so a participant who has not received it yet cannot decrypt anything you publish from that moment until it lands. Keys you set earlier stay available to decrypt frames that are still arriving, which is what makes the switch seamless in the other direction, 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
The manager exposes a Stream<E2eeEvent> you can listen to, to keep your UI in sync and to react to problems. Match on event.type.
| Event type | What it means | What to do |
|---|---|---|
E2eeEventType.decryptionFailed |
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. |
E2eeEventType.decryptionResumed |
Decryption recovered for a track that had been failing. Pairs one-to-one with decryptionFailed. |
Clear any "encryption problem" indicator you were showing. |
E2eeEventType.missingKey |
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. |
E2eeEventType.encryptionFailed |
A key is set but a frame could not be encrypted, so your outgoing media is not sent. | Check event.reason; verify your key and that the codec is supported. |
E2eeEventType.decryptionStalled |
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. decryptionResumed clears it. |
E2eeEventType.unsupportedVersion |
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. |
E2eeEventType.unencryptedFrame |
A frame arrived without the encryption trailer on an encrypted call. | Usually a peer that has not finished attaching its transform. Persistent reports are worth investigating. |
Each event names the userId it concerns, and the ones about a specific track also carry a trackType. That lets you report a peer's audio and video independently - a peer's video can recover while their audio is still failing.
final subscription = call.e2eeManager?.events.listen((event) {
switch (event.type) {
case E2eeEventType.decryptionStalled:
debugPrint('Cannot decrypt ${event.trackType?.name} from ${event.userId}');
case E2eeEventType.decryptionResumed:
debugPrint('Decryption recovered for ${event.userId}');
case _:
break;
}
});
// later
await subscription?.cancel();decryptionFailed fires on the first frame that will not decrypt, which happens routinely while a rotation is still in flight. For a user-facing "wrong key" message, key off decryptionStalled instead - it only fires once a track has failed for long enough to render nothing.
Inspecting keys and performance
Two further event types exist for diagnostics. Both are driven by you rather than by a problem, and neither arrives unless you ask for it.
requestKeyState() asks for a single E2eeEventType.keyState event describing which keys the manager currently holds - useful when a participant cannot decrypt and you need to know whether the key you think you set actually landed. The fingerprints it reports are digests, not key material, so they are safe to log and to compare between devices.
call.e2eeManager?.events
.where((event) => event.type == E2eeEventType.keyState)
.listen((event) {
final keyState = event.keyState;
if (keyState == null) return;
for (final key in keyState.sharedKeys) {
debugPrint('shared #${key.keyIndex} ${key.fingerprint} (active: ${key.isActive})');
}
for (final key in keyState.perUserKeys) {
debugPrint('${key.userId} #${key.keyIndex} ${key.fingerprint}');
}
});
await call.e2eeManager?.requestKeyState();enablePerformanceReporting(true) turns on periodic E2eeEventType.perfReport events with per-track encrypt and decrypt timings, delivered as E2eeTrackPerf rows in event.encode and event.decode. Leave it off outside diagnostics: it costs a timing measurement per frame.
await call.e2eeManager?.enablePerformanceReporting(true);
call.e2eeManager?.events
.where((event) => event.type == E2eeEventType.perfReport)
.listen((event) {
for (final track in event.encode ?? const <E2eeTrackPerf>[]) {
debugPrint('encode ${track.trackType?.name} ${track.codec} '
'${track.fps} fps, max ${track.maxCryptoMs} ms');
}
});Both lists stay empty until encryptors and decryptors are attached and frames are flowing. A lobby camera preview is local and is not encrypted, so nothing is reported for it.
Putting it all together
A lobby screen that derives a key from a passphrase and joins an encrypted call:
Future<bool> attachEncryption(Call call, String passphrase) async {
if (!EncryptionManager.isSupported) {
// Tell the user this device cannot join encrypted calls.
return false;
}
try {
final keyBytes = await deriveKeyFromPassphrase(passphrase, call.callCid.value);
final e2ee = EncryptionManager.create(userId: client.currentUser.id);
await e2ee.setSharedKey(0, keyBytes);
await call.setE2EEManager(e2ee); // before join()
return true;
} catch (e) {
debugPrint('Failed to enable E2EE: $e');
return false;
}
}
// ...
if (await attachEncryption(call, passphrase)) {
await call.join();
}And the same app answering an encrypted call that arrives through ringing, where there is no screen to do the above from:
StreamVideo(
apiKey,
user: user,
options: StreamVideoOptions(
defaultCallPreferences: DefaultCallPreferences(
encryptionKeyResolver: (request) async {
final bytes = await myKeyStore.keyForCall(request.callCid);
if (bytes == null) return null;
return CallEncryptionKey.shared(bytes: bytes, keyIndex: 0);
},
),
),
);Limitations
- Available on Android, iOS and macOS. Always gate your UI on
EncryptionManager.isSupported. - Works with the Opus, VP8, VP9 and H.264 codecs. AV1 has no framing scheme in this format and is refused rather than encrypted, so a track that would use it is not published.
- Server-side features that need to read the media (recording, transcription, closed captions, thumbnails, HLS broadcasting) cannot work while media is encrypted. The coordinator rejects those requests.
- The key resolver supplies shared keys only. Per-participant keys go through
call.e2eeManager. - You are responsible for generating, distributing, and rotating keys, and for removing keys when participants leave.