Skip to content
Platform docs
Auth, users, webhooks & more

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.

Info:

End-to-end encryption is available from Flutter Video SDK 1.6.0.

Best practices

  • Check EncryptionManager.isSupported before offering E2EE in your UI. It is true on Android, iOS and macOS only.
  • 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 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();
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. 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.

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.

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.

Info:

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 null joins 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.e2eeManager for 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).

Note:

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

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

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.

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();
Info:

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.