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

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.

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:

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().

@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):

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.

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.

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.

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.

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

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.

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:

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(...).

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:

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:

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.