End-to-end encryption
End-to-end encryption (E2EE) encrypts audio, video, and screen sharing on the sender's device. Participants decrypt the media on their own devices using keys supplied by your application. Stream forwards the encrypted media without access to those keys.
The iOS SDK provides EncryptionManager, a framed AES-GCM implementation compatible with Stream's JavaScript and Android SDKs.
Best practices
- Deliver keys through an authenticated, secure channel that your application controls. Never put keys in call custom data, custom events, or other data sent through Stream.
- Use a dedicated call type for encrypted calls so encryption requirements are explicit.
- Rotate keys when participants join or leave, according to your application's security policy.
- Handle setup errors and encryption events. Do not fall back to an unencrypted call when encryption setup fails.
How it works
An EncryptionManager holds keys and encrypts or decrypts media frames. Attach it to a Call with setE2EEManager(_:) before joining. The SDK then attaches encryption to outgoing tracks and decryption to incoming tracks, using the remote participant's user ID to select their keys.
All participants must use compatible SDK versions, the same encryption algorithm, and matching keys and key indexes. The SDK provides APIs to install and remove keys and reports encryption events. Your application generates and distributes keys, decides when to rotate them, and implements recovery when keys are missing or do not match.
E2EE applies to media frames. It does not encrypt call metadata, signaling, or custom events.
Prerequisites
Use an iOS SDK version that includes EncryptionManager and Call.setE2EEManager(_:).
EncryptionManager.isSupported reports support for native encoded transforms and currently always returns true in the iOS implementation. It does not assess device hardware capabilities or whether encryption is enabled for a call.
In the Stream Dashboard, create a dedicated call type, such as e2ee, under Video & Audio > Call Types. Set its Encryption Mode to auto-on.
Stream's backend enforces the encryption mode. The iOS SDK sends encryption settings when creating a call and reports whether an encryption manager is attached in the join request.
| Mode | Behavior |
|---|---|
auto-on |
Calls require encryption. |
available |
Calls can use encryption when enabled through a settings override at call creation. |
disabled |
Calls do not allow encryption. |
The encryption mode is fixed when a call is created. You cannot turn an existing unencrypted call into an encrypted call, or disable encryption on an existing encrypted call.
Enabling E2EE on a call
Create the manager after initializing your StreamVideo client. Use the same user ID as the authenticated local user, install your application's key, and attach the manager before joining:
import Foundation
import StreamVideo
let call = streamVideo.call(callType: "e2ee", callId: "my-call-id")
let encryption = try EncryptionManager(userId: streamVideo.user.id)
// sharedKey is a 16-byte key obtained through your secure key channel.
try encryption.setSharedKey(0, rawKey: sharedKey)
try await call.setE2EEManager(encryption)
try await call.join(create: true)This example uses a call type configured with auto-on. Every participant must attach a manager before joining the encrypted call. The backend rejects joining an encrypted call without a manager, or joining with a manager when the call does not allow encryption.
For a call type configured with available, enable encryption when creating a new call:
try await call.create(
encryption: EncryptionSettingsRequest(mode: .autoOn)
)
try await call.join()Attach the manager and set its keys before the join() in this example too. A creation override does not change the encryption mode of an existing call.
setE2EEManager(_:) is an asynchronous, throwing operation. Attaching, replacing, or clearing the manager after joining throws. Handle errors from manager initialization, key installation, attachment, and joining in your application's call setup flow.
Key management
The default algorithm is .aes128Gcm, which requires exactly 16 bytes of key material. For AES-256-GCM, create the manager with .aes256Gcm and supply exactly 32 bytes:
let encryption = try EncryptionManager(
userId: streamVideo.user.id,
algorithm: .aes256Gcm
)All participants must select the same algorithm. Key setters throw if the key length or index is invalid.
Shared key
Use setSharedKey(_:rawKey:) to install a key that all participants share:
try encryption.setSharedKey(0, rawKey: sharedKey)Every participant must receive the same key bytes and install them at the same index. A shared key is used when no per-participant key is available for the relevant user.
You can generate a random AES-128 key with CryptoKit:
import CryptoKit
import Foundation
let key = SymmetricKey(size: .bits128)
let sharedKey = key.withUnsafeBytes { Data($0) }Generate the shared key once, then distribute it securely to the intended participants. Generating a different random key on each device will prevent participants from decrypting each other's media. For AES-256, use .bits256.
Per-participant key
Use setKey(_:keyIndex:rawKey:) to install a key for a specific user:
// Each recipient installs Alice's key under Alice's Stream user ID.
try encryption.setKey("alice", keyIndex: 0, rawKey: aliceKey)Install the local user's key to encrypt outgoing media. Install each remote user's key under their Stream user ID to decrypt their media. Your application must distribute each key to every participant who should receive that user's media.
Key index
Key indexes are integers from 0 through 255. Outgoing frames carry their key index so receivers can select the matching key.
The most recently installed key for the local user is used for outgoing media. If no local per-participant key is available, the most recently installed shared key is used. The index is a slot identifier; a higher number does not automatically make a key active.
Key rotation
Install a replacement key at a new index rather than overwriting a key that may still be needed for in-flight frames:
// nextSharedKey has been delivered through your secure key channel.
try encryption.setSharedKey(1, rawKey: nextSharedKey)Installing the new key also makes it active for outgoing media that uses the shared key. Coordinate key delivery and activation across participants so receivers have the replacement key when it is needed. Keep the previous key while frames encrypted with it may still arrive.
Once your application determines that the old key is no longer needed, remove that exact index:
try encryption.removeSharedKey(0)
// Equivalent operations for a per-participant key.
try encryption.removeKey("alice", keyIndex: 0)
try encryption.removeAllKeys("alice")Removing the active key does not activate an older key. If you remove the local user's active per-participant key, outgoing encryption falls back to the active shared key if one exists. Without an applicable key, outgoing media is not sent.
Reacting to encryption events
Subscribe to eventPublisher before joining so your application can react to encryption problems. The SDK reports the events; your application implements key recovery and UI updates:
import Combine
let encryptionSubscription = encryption.eventPublisher
.receive(on: DispatchQueue.main)
.sink { event in
switch event.name {
case "e2ee.missing_key":
// Recover the required key through your secure key channel.
print("Missing key for user: \(event.userId)")
case "e2ee.decryption_failed", "e2ee.decryption_stalled":
// Show a media decryption error for this participant.
print("Unable to decrypt media for user: \(event.userId)")
case "e2ee.decryption_resumed":
// Clear the participant's media decryption error.
print("Decryption resumed for user: \(event.userId)")
default:
break
}
}Retain the subscription for as long as you need to receive events. E2EEEvent includes name and userId, plus optional trackType, keyIndex, version, and reason fields.
| Event | Meaning and action |
|---|---|
e2ee.missing_key |
A required key is missing. Obtain and install it through your secure key channel. |
e2ee.encryption_failed |
An outgoing frame could not be encrypted and is not sent. Inspect the reason and check the key and codec configuration. |
e2ee.decryption_failed |
A received frame could not be decrypted. Check the sender's key and index. |
e2ee.decryption_stalled |
Repeated decryption failures prevent a participant's track from rendering. Surface the problem and recover the keys. |
e2ee.decryption_resumed |
Decryption recovered after a failure. Clear the error for that participant and track. |
e2ee.unencrypted_frame |
An incoming frame was unencrypted. Verify that every participant attached a manager before joining. |
e2ee.unsupported_version |
A received frame uses an unsupported framing version. Update the receiving application to a compatible SDK version. |
Track successful setup in your application and use encryption events to report media problems. Attaching a manager alone does not prove that every participant has matching keys or that their media is decrypting successfully.
Lifecycle and cleanup
The attached manager survives reconnection and call.leave(). Rejoining the same Call uses that manager again. If you create a new Call instance, attach a manager before joining it.
Before joining, you can clear the attachment with try await call.setE2EEManager(nil). This does not disable encryption on an encrypted call; joining that call without a manager will be rejected.
Keep the manager alive while it is in use. EncryptionManager releases its native resources when deallocated. You can also call dispose() explicitly when no call uses it anymore, but a disposed manager cannot be reused. Do not dispose a manager that you intend to use for reconnection or rejoining.
Limitations
- E2EE requires every participant to use a compatible client and the correct keys. A key mismatch prevents affected media from being decrypted.
- Stream cannot decrypt encrypted media for server-side recording, transcription, or HLS broadcasting. Use client-side alternatives if your application needs those features.
- E2EE does not replace participant authentication, call permissions, or a secure key-distribution system.