# 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 Android Video SDK `1.33.0`.
>

## Best Practices

- Check `StreamEncryptionManager.isSupported()` before offering E2EE in your UI.
- Create the manager **after** the `StreamVideo` client is built. Creation allocates native WebRTC state.
- 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.
- You own the manager. `leave()` destroys that `Call` instance and does not dispose the manager. Attach the same instance to the next `client.call(...)` before `join()`, and call `dispose()` when you are done with every call that used it.

## 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](https://getstream.io/video/docs/android/guides/client-auth/) and [Joining & Creating Calls](https://getstream.io/video/docs/android/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 a `StreamEncryptionManager`, sets a key, attaches it to the call, and joins. The manager must be attached before `join()`, because the publisher and subscriber capture it when the session is created, and the join request tells the coordinator whether this device is encrypted.

```kotlin
import io.getstream.video.android.core.e2ee.StreamEncryptionManager

val call = client.call("e2ee", "my-call-id")
if (StreamEncryptionManager.isSupported()) {
    val e2ee = StreamEncryptionManager.create(call.user.id).getOrThrow()
    // a 16-byte key (see "Key management" below for how to produce one)
    e2ee.setSharedKey(keyIndex = 0, key = key)
    call.setE2EEManager(e2ee) // must happen before join()

    // the e2ee call type is already encrypted, so no settings are needed here
    call.create()
    call.join()
}
```

Notice that the snippet passes no encryption settings: the `e2ee` call type already carries the mode, so there is nothing to override.

`StreamEncryptionManager.create` and `call.setE2EEManager` return `kotlin.Result`. Check the result (or use `getOrThrow()`) before joining: attaching after `join()` fails, and joining without a manager on an encrypted call is rejected.

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

```kotlin
import io.getstream.android.video.generated.models.CallSettingsRequest
import io.getstream.android.video.generated.models.EncryptionSettingsRequest

call.create(
    settings = CallSettingsRequest(
        encryption = EncryptionSettingsRequest(
            mode = EncryptionSettingsRequest.Mode.AutoOn,
        ),
    ),
)
```

### 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 overridden 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:

```kotlin
import io.getstream.video.android.core.e2ee.E2EEAlgorithm

val e2ee = StreamEncryptionManager.create(
    userId = call.user.id,
    algorithm = E2EEAlgorithm.AES_256_GCM, // default is AES_128_GCM
).getOrThrow()
```

Every participant must use the same algorithm and a matching key length, or frames will not decode.

### Checking whether E2EE is active

Collect `call.state.e2eeEnabled` to render an indicator, such as a lock badge. It becomes `true` as soon as a manager is attached with `setE2EEManager`, including on a lobby screen before `join()`.

```kotlin
@Composable
fun EncryptionBadge(call: Call) {
    val e2eeEnabled by call.state.e2eeEnabled.collectAsStateWithLifecycle()
    if (!e2eeEnabled) return
    Icon(
        imageVector = Icons.Default.Lock,
        contentDescription = "This call is end-to-end encrypted",
    )
}
```

`e2eeEnabled` only reports that a manager is attached. It is not a runtime-health flag: decryption failures arrive on the manager's event listener, not through this state. It flips back to `false` when `leave()` destroys the call, so a lock badge on that instance goes away with the call.

Whether the call **type** requires encryption is a separate signal. Read it from the settings Stream resolved for the call (call type plus any per-call override):

```kotlin
import io.getstream.android.video.generated.models.EncryptionSettingsResponse

val settings by call.state.settings.collectAsStateWithLifecycle()
val willBeEncrypted =
    settings?.encryption?.mode == EncryptionSettingsResponse.Mode.AutoOn
```

Use the mode before joining to decide whether to show the encryption toggle. Once a manager is attached, `e2eeEnabled` is the signal for the lock indicator.

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

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). You do **not** 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.

```kotlin
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.PBEKeySpec

fun deriveKeyFromPassphrase(passphrase: String, salt: String): ByteArray {
    val spec = PBEKeySpec(
        passphrase.toCharArray(),
        salt.toByteArray(Charsets.UTF_8),
        100_000,
        128, // use 256 for AES-256-GCM
    )
    return SecretKeyFactory
        .getInstance("PBKDF2WithHmacSHA256")
        .generateSecret(spec)
        .encoded
}

val key = withContext(Dispatchers.Default) {
    // salt per call/room, not one app-wide value
    deriveKeyFromPassphrase("our-shared-secret", salt = "${call.type}:${call.id}")
}
e2ee.setSharedKey(keyIndex = 0, key = key)
```

PBKDF2 is CPU-heavy; run it off the main thread. The first argument to `setSharedKey` is the key index, used for rotation (see below).

>
> **Note:** A passphrase-derived key is a demo-friendly way to get every device onto the same material. Derive the salt per call or room: a static app-wide salt lets one precomputation cover every room. Real integrations generate and distribute keys out of band. 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, 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.

```kotlin
import io.getstream.android.video.generated.models.CallSessionParticipantJoinedEvent
import io.getstream.android.video.generated.models.CallSessionParticipantLeftEvent
import kotlinx.coroutines.flow.filterIsInstance

// your own key - keep the index around so you can rotate it later
val (myKey, myKeyIndex) = fetchAndDistributeKeyForUser(currentUserId)

// register our own key in our e2ee manager
e2ee.setKey(currentUserId, myKeyIndex, myKey)

viewModelScope.launch {
    call.events.filterIsInstance<CallSessionParticipantJoinedEvent>().collect { event ->
        val userId = event.participant.user.id

        // fetch this participant's key over your own secure channel, then:
        val (theirKey, keyIndex) = fetchAndDistributeKeyForUser(userId)

        // register their key in our e2ee manager
        e2ee.setKey(userId, keyIndex, theirKey)
    }
}

viewModelScope.launch {
    call.events.filterIsInstance<CallSessionParticipantLeftEvent>().collect { 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
        val (myNewKey, nextKeyIndex) = 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.

```kotlin
// your secure channel notifies you that a participant rotated their key
fun onRemoteKeyRotated(userId: String, keyIndex: Int, key: ByteArray) {
    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 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, 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, 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.

```kotlin
val 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.

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

Register a listener on the manager to keep your UI in sync and to react to problems. The callback runs on a **WebRTC internal thread**, so hop to your own dispatcher before touching UI state. Pass `null` to `setEventListener` to stop observing.

| Event                 | What it means                                                                                                                                                                                                           | What to do                                                                                                                      |
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `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.                                               |
| `DECRYPTION_RESUMED`  | Decryption recovered for a track that had been failing. Pairs one-to-one with `DECRYPTION_FAILED`.                                                                                                                      | Clear any "encryption problem" indicator you were showing.                                                                      |
| `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. |
| `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.                                                     |
| `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. `DECRYPTION_RESUMED` clears it.                                               |
| `UNENCRYPTED_FRAME`   | A remote frame had no E2EE trailer and was passed to the decoder as cleartext.                                                                                                                                          | Unexpected on an encrypted call; confirm every participant attached a manager before joining.                                   |
| `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.

```kotlin
import io.getstream.video.android.core.e2ee.E2EEEventType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

e2ee.setEventListener { event ->
    if (event.type != E2EEEventType.DECRYPTION_FAILED) return@setEventListener
    viewModelScope.launch(Dispatchers.Main) {
        Log.w("E2EE", "Could not decrypt ${event.trackType} from ${event.userId}")
    }
}
```

For debugging you can also call `e2ee.requestKeyState()` (delivered as an `E2EEEventType.KEY_STATE` event) to inspect which keys the SDK currently holds. Fingerprints in that report are digests, not key material, and are safe to log.

`e2ee.enablePerformanceReporting(true)` turns on periodic `PERF_REPORT` events with per-track encrypt/decrypt timings. Leave it off outside diagnostics: it costs a timing measurement per frame. The encode/decode lists stay empty until encryptors and decryptors are attached **and** frames are flowing. A lobby camera preview is local and is not encrypted.

## Putting it all together

A minimal end-to-end flow:

```kotlin
import io.getstream.video.android.core.StreamVideoBuilder
import io.getstream.video.android.core.e2ee.StreamEncryptionManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext

val client = StreamVideoBuilder(
    context = context,
    apiKey = apiKey,
    user = user,
    token = token,
).build()

val call = client.call("e2ee", "my-call-id")

if (StreamEncryptionManager.isSupported()) {
    val e2ee = StreamEncryptionManager.create(call.user.id).getOrThrow()
    val key = withContext(Dispatchers.Default) {
        deriveKeyFromPassphrase("our-shared-secret", salt = "${call.type}:${call.id}")
    }
    e2ee.setSharedKey(keyIndex = 0, key = key)

    call.setE2EEManager(e2ee) // assign the encryption manager
    call.create()
    call.join()
}
```

The same flow inside a `ViewModel`. This example owns one `Call` for the lifetime of the screen. After `leave()` that instance is destroyed and cannot be joined again. Keep the manager only if you will attach it to a later `client.call(...)`.

```kotlin
class EncryptedCallViewModel(
    private val client: StreamVideo,
) : ViewModel() {

    // Single-use: leave() destroys this Call. A later join needs a new client.call(...).
    val call: Call = client.call("e2ee", "my-call-id")
    private var e2ee: StreamEncryptionManager? = null

    suspend fun joinEncrypted(passphrase: String) {
        check(StreamEncryptionManager.isSupported()) {
            "E2EE is not available on this device."
        }

        val key = withContext(Dispatchers.Default) {
            deriveKeyFromPassphrase(passphrase, salt = "${call.type}:${call.id}")
        }
        val manager = StreamEncryptionManager.create(call.user.id).getOrThrow()

        manager.setSharedKey(keyIndex = 0, key = key)
        manager.setEventListener { event ->
            if (event.type == E2EEEventType.DECRYPTION_FAILED) {
                Log.w("E2EE", "Could not decrypt media from ${event.userId}")
            }
        }

        call.setE2EEManager(manager).onFailure {
            manager.dispose()
        }.getOrThrow()
        e2ee = manager

        call.create()
        call.join()
    }

    override fun onCleared() {
        super.onCleared()
        call.leave()
        e2ee?.dispose()
        e2ee = null
    }
}
```

>
> **Info:** The manager outlives the call. `leave()` destroys the `Call` instance; to join again you create a new one with `client.call(...)` and attach the same manager to it before `join()`. That is why the SDK never disposes it for you: call `dispose()` only when you are done with every call that used it.
>
> Reconnects and SFU migrations keep the same `Call` and never go through `leave()`, so encryption stays attached. A user-level leave ends it.
>

## Custom E2EEManager implementation

>
> **Warning:** This is an advanced topic. Most apps should use the built-in `StreamEncryptionManager`. 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 a hardware keystore or a regulator-mandated cipher) and own the entire crypto path. These two methods are the whole contract - the SDK calls them when it attaches media:

```kotlin
import io.getstream.video.android.core.e2ee.E2EEManager
import io.getstream.video.android.core.e2ee.E2EETrackType
import org.webrtc.RtpReceiver
import org.webrtc.RtpSender

interface E2EEManager {
    fun encrypt(sender: RtpSender, codec: String?, trackType: E2EETrackType?): Result<Unit>
    fun decrypt(receiver: RtpReceiver, userId: String, trackType: E2EETrackType?): Result<Unit>
}
```

`trackType` is an optional label (`AUDIO`, `VIDEO`, `SCREEN_SHARE`, `SCREEN_SHARE_AUDIO`) the SDK passes so implementations can key or tag frames per media type; a custom manager can ignore it. `codec` is the negotiated codec, lowercased and without a MIME prefix (`opus`, `vp8`, `av1`), or `null` when the SDK cannot determine it.

If `encrypt` returns a failure, the SDK **stops that transceiver and does not publish**. That is deliberate: failing open would send plaintext on a call the app believes is encrypted.

A minimal skeleton:

```kotlin
class MyEncryptionManager : E2EEManager {
    override fun encrypt(
        sender: RtpSender,
        codec: String?,
        trackType: E2EETrackType?,
    ): Result<Unit> = runCatching {
        // install your frame encryptor on sender
    }

    override fun decrypt(
        receiver: RtpReceiver,
        userId: String,
        trackType: E2EETrackType?,
    ): Result<Unit> = runCatching {
        // install your frame decryptor on receiver
    }
}

call.setE2EEManager(MyEncryptionManager()) // before join()
call.join()
```

With a custom manager you are responsible for all key management. The built-in key methods (`setKey`, `setSharedKey`, ...) and `E2EEEvent` types do not apply.

## Limitations

- Gate your UI on `StreamEncryptionManager.isSupported()`. It is `false` only if the app overrides the WebRTC dependency with a build older than the one this SDK compiles against.
- Create the manager after `StreamVideo` is built, and attach it before `join()`.
- 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.
- You are responsible for generating, distributing, and rotating keys, and for removing keys when participants leave.
- `leave()` destroys that `Call` and does not dispose the manager. Call `dispose()` when you no longer need it.

---

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