How Does End-to-End Encryption Work for Video Calls?

WebRTC encrypts video by default, but that's not the same guarantee as end-to-end encryption once a call runs through a server. We're covering what closes that gap.

TL;DR

  • WebRTC already encrypts every hop, but in a group call, the SFU is one of those hops. So, "encrypted in transit" and "end-to-end encrypted" are different claims once more than two people join.
  • A second encryption layer, SFrame, fixes this: each frame is encrypted on the sending device before SRTP wraps it, so the SFU can route packets by their headers without ever seeing the media.
  • Getting real E2EE also means binding keys to verified device identities, agreeing on group keys via sender keys or MLS, and rotating those keys on every join and leave.
  • Turning on E2EE means giving up any server-side feature that needs to read the media (cloud recording, transcription, AI summaries), and Stream Video now supports it natively through EncryptionManager.

End-to-end encryption, or E2EE, is hardly new.

For text, the concept has been around since PGP in 1991, and it has been enabled in WhatsApp since 2016. And in text, the mental model is easy to understand. Alice encrypts a message with Bob's public key, the server passes the ciphertext along without being able to read it, and Bob decrypts it with a private key that never leaves his phone.

But how does that work with video? For a recorded video, sure, you can imagine something similar. But live video? Does every frame need to be encrypted? Does the server need to see the video to route it? Does the key have to change every time someone joins or leaves the call?

If you're building video calling, someone will eventually want the answer to all those questions (Yes. No. Yes.) It might be a customer's security team, a compliance reviewer, or just an end user who is concerned about privacy.

But getting to a real E2EE call takes more than encrypting the frames. You have to:

  • Prove which devices are in the call.
  • Agree on keys between them without the server being able to substitute its own.
  • Encrypt each frame after the codec has compressed it.
  • Rotate the keys whenever someone joins or leaves.
  • Leave enough packet metadata visible for the server to route media in real time.

This is what we're going to take you through here: the nuts and bolts of E2EE for video.

Encrypted in Transit Is Not the Same as End-to-End Encrypted

WebRTC already encrypts all media, all the time. Every peer connection runs a datagram transport layer security (DTLS) handshake, and the keys that come out of it protect the real-time transport protocol (RTP) packets with secure reliable transport protocol (SRTP). So asking whether the video is encrypted gets you nowhere, because it always is.

What you want to know is where each encrypted connection starts and stops. Take the simplest case, a one-to-one call with a direct path between the two devices. The DTLS-SRTP connection runs from Alice's laptop to Bob's phone, and nothing in between can decrypt a packet. If Alice and Bob have also verified each other's identity, that call is end-to-end encrypted, and nobody had to do anything special. It is PGP in video form.

Now put network address translation (NAT) in the way. If ICE can't find a direct path, it falls back to a TURN relay. Does that break E2EE? No. TURN forwards packets at the transport level and never joins the DTLS handshake, so it has no keys. It sees IP addresses, timing, and packet sizes, and nothing more. The call is still end-to-end encrypted.

But group calls are different. The usual architecture is a selective forwarding unit (SFU) which takes in each participant's media and forwards it to everyone else. In conventional WebRTC, each participant establishes its own DTLS-SRTP connection with the SFU. So the SFU is the endpoint of every connection and holds the keys to all of them. Each hop is encrypted, but the middle server can read everything.

Direct one-to-one call, a call through a TURN relay, and a group call through an SFU, showing who holds the encryption keys in each

The pattern is the same in all three. Encryption ends wherever the DTLS handshake ends, and that's the two devices in the first two cases and the server in the third. Nothing about the relay changes that, and nothing about the SFU can avoid it, because the SFU has to be an endpoint to forward anything at all.

Architecture Where the media encryption ends What the server can see End-to-end encrypted?
Direct WebRTC Participant devices Packet metadata Yes, if the peers are authenticated
WebRTC through TURN Participant devices IP addresses, timing, packet sizes Yes. TURN only relays packets
SFU with SRTP only Participant device and SFU Media and routing metadata No. Encrypted in transit only
SFU with SFrame over SRTP SFrame at the devices, SRTP at the SFU Routing metadata and opaque media Yes
Server-side mixer (MCU) Participant device and mixer Decoded audio and video No

How Group Calls Get End-to-End Encryption Anyway

So how do you get E2EE through an SFU?

You add a second layer of encryption that the SFU can't open. Each frame is encrypted on the sending device right after the codec, before the packets are handed to SRTP. The SFU strips SRTP, finds an opaque encrypted frame inside, forwards it, and wraps it in fresh SRTP for the next hop. It never has the key for the inner layer. That inner layer is SFrame.

An RTP packet showing SRTP wrapping an SFrame ciphertext, with the RTP header still readable by the SFU

SRTP encrypts the RTP payload and authenticates the header, so the SFU can still read sequence numbers, timestamps, and stream IDs, and rewrite them as it forwards. What sits inside the payload is an SFrame ciphertext, which it can carry but not open. One encoded frame is usually spread across several RTP packets, so the diagram shows the layering rather than the exact bytes of one packet.

The other kind of group call server, an MCU, can't be rescued this way. It decodes every stream to composite them into one picture, and you can't composite what you can't decode. A product built on a mixer can't be end-to-end encrypted.

Architecture Where the media encryption ends What the server can see End-to-end encrypted?
SFU with SFrame over SRTP SFrame at the devices, SRTP at the SFU Routing metadata and opaque media Yes
Server-side mixer (MCU) Participant device and mixer Decoded audio and video No

The WebRTC security architecture covers the outer layer. From here on, most of the work is in the inner one.

What Happens in an End-to-End Encrypted Call

A group call through an SFU requires all of the pieces below. A one-to-one call uses a subset of them, since its DTLS-SRTP connection can already be end-to-end.

1. Identifying the Devices

If we head back to even that initial WebRTC version of encryption, when Bob's phone decrypts a frame, what has actually been proven? Only that whoever decrypted it had the key. Nothing about the encryption says that key belongs to Alice's phone rather than to the server that handed it out.

So, before any media keys exist, each device needs an identity that the other participants can verify. That might be a long-term identity key, a device certificate signed by the provider, or a credential tied to the user's account. What matters is that the media keys get bound to it somehow.

How that binding happens varies by product:

  • Signing the ephemeral call keys with the device's identity key, so the other side can verify who generated them.
  • Binding the WebRTC certificate fingerprints exchanged in signaling to an authenticated account.
  • Showing a short security code that participants compare out of band.
  • Publishing device keys in a transparency log, or tracking safety numbers, so a swapped key is detectable.

Why does this matter? Because the signaling server sits between every key exchange. If nothing ties keys to identities, a malicious or compromised signaling service can hand each caller a key of its own choosing and quietly decrypt both directions of the call, and neither caller will see anything wrong.

2. Signaling the Call

Now the devices need to find each other. They do that through the signaling service, over TLS (HTTPS or a WebSocket), and they trade everything the call needs to get going:

  • Codec and media capabilities.
  • ICE candidates, the possible network paths to each device.
  • DTLS certificate fingerprints.
  • Which participants and devices are joining.
  • The messages the E2EE key protocol needs, such as wrapped keys or group updates.
Sequence diagram showing Alice and Bob exchanging offer/answer and a wrapped media key through the signaling service, which can see call metadata but never the media

Is any of that hidden from the service? Mostly not. The media will be, once the call is up. The signaling generally isn't. The provider still learns who called whom, when the call started, which devices joined, and how long it lasted.

This is important because "the server can't read the call" and "the server knows nothing about the call" are two different claims.

3. Finding a Media Path and Setting Up the Transport Layer

With signaling done, the devices have a list of possible routes to each other, and ICE works through them until one connects. A direct path wins if there is one. If NATs or firewalls block every direct option, ICE settles for a TURN relay.

Once there's a path, the two endpoints run a DTLS handshake over it. This is where the fingerprint from signaling earns its keep. DTLS verifies that the certificate on the wire matches the fingerprint each side was told to expect; if it does, it exports keys for SRTP. From that point on, every RTP packet carrying media is encrypted and authenticated.

Flowchart: ICE tries candidate pairs, picks a direct path or TURN relay, runs a DTLS handshake, and checks the certificate against the signaling fingerprint before exporting SRTP keys

Is that the end-to-end layer? For a one-to-one call, direct or through TURN, it can be. The handshake ran between Alice's device and Bob's, so the keys live only there. In an SFU call, it's not, because each participant ran that handshake with the server. The connection is secure, but it only reaches as far as the SFU. The end-to-end layer has to come from somewhere else.

4. Agreeing on the Group Keys

So now you need the inner layer, and that starts with keys. In a group call, every participating device needs a set of keys that no one else has, including the server that just set up the call. How do you get there?

Sender Keys

The first answer is the simple one. Each participant generates a symmetric key for its outgoing media and sends it to everyone else over a channel that's already end-to-end encrypted, such as an existing messaging session. To decrypt Alice's video, you need Alice's key. To decrypt Bob's, you need Bob's. Signal's group calls work this way. Each client makes its own media key, distributes it through E2EE Signal messages, and replaces it whenever someone joins or leaves.

For the media path itself, this is about as cheap as it gets, since each receiver just needs the right sender's key. Where it gets expensive is membership changes. Every sender has to get a replacement key to every other participant, and in a big call that starts to look like quadratic work.

MLS

The second answer is Messaging Layer Security, which keeps a single shared group secret within a tree of keys. Each membership state is an epoch. Add or remove a device, and you get a new epoch secret that only the current members can derive. Because the keys are in a tree, an update costs logarithmic work in the group size rather than linear, and you get a few properties as standard:

  • Forward secrecy, so compromising a device later doesn't reveal earlier epochs.
  • Post-compromise security, so once an uncompromised update happens, an attacker who had the old keys loses access to future epochs.
  • Authenticated membership changes, so participants can tell who added or removed whom.
  • Efficient removal of a member.

One thing MLS doesn't do is encrypt video. It establishes and rotates keys, and it can export one for a frame-level protocol to use. That's how the two fit together, and the frame-level protocol is what comes next.

5. Encoding, Then Encrypting Each Frame

Back to the first of those three questions. Does every frame need to be encrypted? Yes, and the order matters. Encryption has to happen after compression, not before. A video codec works by finding redundancy within and between frames, and encrypted data has none. Encrypt first; if the codec has nothing to compress, the bitrate explodes.

So on the sending device, the order is:

  • Capture from the camera or microphone.
  • Apply any local processing, such as background blur or noise suppression.
  • Encode with the negotiated codec, whether that's VP8, VP9, AV1, H.264, or Opus for audio.
  • Encrypt the encoded frame with the E2EE key for this sender.
  • Split the encrypted frame into RTP packets.
  • Apply SRTP for the connection to the SFU.
  • Send.
Pipeline diagram: capture, local processing, encode, encrypt the frame via WebRTC Encoded Transform, packetize, apply SRTP, send

Step 4 is where SFrame comes in. Each frame that comes out of the encoder goes in, and what comes out is a small header plus the ciphertext:

  • A key ID, so the receiver knows which base key applies.
  • A counter, which is combined with a salt to build a nonce that is unique for every frame under that key.
  • The encrypted encoded frame.
  • An authentication tag, so any modification in transit is detected.

Under the hood, SFrame derives a working key and salt from the base key using HKDF, and it authenticates the header as associated data, so an intermediary can't tamper with the key ID or counter without the tag failing. Two rules keep it safe. Never reuse a key-counter pair, and never let two senders share an encryption key.

How do you actually get at the frames to do this? In the browser, the hook is WebRTC Encoded Transform. It hands you each encoded frame between the encoder and the packetizer, so you can modify it before it's sent, and it does the same on the receiving side before the decoder receives it. Your transform runs in a worker.

Here's what that looks like in practice, with the pieces reduced to the minimum. On the main thread, you generate a key, hand it to the worker, and attach a transform to each sender and receiver:

const worker = new Worker('e2ee-worker.js');

// In a real system this is where the key agreement protocol delivers keys.
const key = await crypto.subtle.generateKey(
  { name: 'AES-GCM', length: 128 }, false, ['encrypt', 'decrypt'],
);
worker.postMessage({ type: 'set-key', keyId: 1, key });

// Outgoing media: one transform per sender
const sender = pc.addTrack(videoTrack, localStream);
sender.transform = new RTCRtpScriptTransform(worker, { role: 'encrypt' });

// Incoming media: one transform per receiver, attached before the answer
// is created so no early frames reach the decoder unchecked
for (const receiver of pc.getReceivers()) {
  receiver.transform = new RTCRtpScriptTransform(worker, { role: 'decrypt' });
}

The worker is where the frames pass through. Each encoded frame arrives on a readable stream, is encrypted, and is sent over a writable stream to the packetizer. The receiving side reverses it:

// e2ee-worker.js
const keys = new Map();     // keyId -> CryptoKey, delivered by postMessage
const counters = new Map(); // keyId -> BigInt frame counter
let currentKeyId = null;

// VP8 key frames start with a 10-byte header and delta frames with a 3-byte
// frame tag. This example leaves these in the clear so a decoder without the
// key still parses the frame and renders noise. SFrame encrypts everything.
const prefixLength = (frame) => (frame.type === 'key' ? 10 : 3);

// The 12-byte AES-GCM IV: 4 zero bytes, then the frame counter.
function makeIv(counter) {
  const iv = new Uint8Array(12);
  new DataView(iv.buffer).setBigUint64(4, counter);
  return iv;
}

// The 9-byte trailer: key ID, then the frame counter.
function makeTrailer(keyId, counter) {
  const trailer = new Uint8Array(9);
  trailer[0] = keyId;
  new DataView(trailer.buffer).setBigUint64(1, counter);
  return trailer;
}

function concat(a, b) {
  const out = new Uint8Array(a.length + b.length);
  out.set(a, 0);
  out.set(b, a.length);
  return out;
}

async function encryptFrame(frame, controller) {
  const key = keys.get(currentKeyId);
  if (!key) return; // never let plaintext out by accident

  const data = new Uint8Array(frame.data);
  const prefix = data.subarray(0, prefixLength(frame));
  const body = data.subarray(prefix.length);

  // Each key has its own counter starting at 0, so an IV never repeats.
  const counter = counters.get(currentKeyId) ?? 0n;
  counters.set(currentKeyId, counter + 1n);
  const trailer = makeTrailer(currentKeyId, counter);

  // Prefix and trailer are bound as additionalData, so neither can be
  // changed in flight without the tag check failing on the receiver.
  const ciphertext = new Uint8Array(
    await crypto.subtle.encrypt(
      { name: 'AES-GCM', iv: makeIv(counter), additionalData: concat(prefix, trailer) },
      key,
      body,
    ),
  );

  // [prefix][ciphertext + 16-byte tag][trailer]
  frame.data = concat(concat(prefix, ciphertext), trailer).buffer;
  controller.enqueue(frame);
}

async function decryptFrame(frame, controller) {
  const data = new Uint8Array(frame.data);
  const prefix = data.subarray(0, prefixLength(frame));
  const trailer = data.subarray(data.length - 9);
  const keyId = trailer[0];
  const counter = new DataView(trailer.buffer, trailer.byteOffset).getBigUint64(1);

  const key = keys.get(keyId);
  if (!key) return; // unknown key ID: drop the frame until that key arrives

  const ciphertext = data.subarray(prefix.length, data.length - 9);
  let body;
  try {
    body = new Uint8Array(
      await crypto.subtle.decrypt(
        { name: 'AES-GCM', iv: makeIv(counter), additionalData: concat(prefix, trailer) },
        key,
        ciphertext,
      ),
    );
  } catch {
    return; // failed tag: never hand an unauthenticated frame to the decoder
  }

  frame.data = concat(prefix, body).buffer;
  controller.enqueue(frame);
}

onrtctransform = (event) => {
  const { readable, writable, options } = event.transformer;
  const transform = options.role === 'encrypt' ? encryptFrame : decryptFrame;
  readable.pipeThrough(new TransformStream({ transform })).pipeTo(writable);
};

onmessage = ({ data: msg }) => {
  if (msg.type === 'set-key') {
    keys.set(msg.keyId, msg.key);
    if (currentKeyId === null) currentKeyId = msg.keyId;
  } else if (msg.type === 'use-key') {
    currentKeyId = msg.keyId; // the sender rotates by pointing at a new key
  }
};

Is this SFrame? Not quite, though it's the same idea. Proper SFrame places a compact header at the front of the payload rather than a trailer at the end, derives working keys and salts with HKDF, encrypts the entire frame, including the codec header, and supports a truncated-tag variant for low-bitrate audio. The structure is the same, though. Every frame that leaves the encoder is encrypted under a key ID and counter, and nothing between the two workers can read it.

A demo showing three video panels: the plaintext camera feed, the decrypted feed on a receiver with the key, and noise on a receiver without it

Put a frame through this, and you can see the effect byte by byte. The first three bytes of a delta frame are the same before and after, since that's the VP8 frame tag left in the clear, and everything after them differs. Every encrypted frame is 25 bytes longer than the plaintext: 16 bytes for the authentication tag and 9 bytes for the trailer. That's a small fraction of a typical frame.

Building your own app? Get access to our Livestream or Video Calling API and launch in days!

6. Forwarding Through the SFU

Now the second question. Does the server need to see the video to route it? No. This is the part that makes group-call E2EE work at all.

When a packet arrives at the SFU, the server strips the outer SRTP layer, as it always has. What it finds inside is an SFrame ciphertext, so it can't decode the picture or hear the audio. But it can still read the RTP headers and the feedback coming back from receivers, and it turns out that's all it needs. With nothing but transport-level information, the SFU can:

  • Estimate available bandwidth from receiver feedback.
  • Pick a simulcast or SVC layer for each receiver.
  • Forward only the active speakers or the visible tiles.
  • Rewrite sequence numbers, timestamps, and stream identifiers as it switches layers.
  • Request retransmissions or a new keyframe.
  • Enforce bandwidth caps.

Then it wraps the frame in fresh SRTP for the connection to each receiver and sends it on. So an E2EE group call runs two encryption layers at once, with different endpoints for each.

Layer Endpoints What it protects
SFrame Sending device to receiving devices Keeps the media unreadable at the SFU
SRTP Each device to the SFU Protects packets and routing metadata on each network hop

Here's the screenshot again:

A demo showing three video panels: the plaintext camera feed, the decrypted feed on a receiver with the key, and noise on a receiver without it

The right-hand panel is what you get when you feed those encrypted frames to a decoder without a key, which is exactly the position an SFU is in. It can tell where each frame starts and which frames are key frames, and it can't make a picture out of any of them.

Why leave the routing information readable at all? Because an SFU that can't read headers can't do anything. Encrypt the RTP headers and feedback end-to-end, so the server can't pick layers or recover from packet loss, and those two things are most of what an SFU is for. SFrame leaves them in the clear on purpose. Signal's SFU shows how much you can still do inside that limit. Each sender uploads a few resolution layers once, and the server decides which encrypted layer each receiver gets, with congestion control and packet rewriting working as normal.

7. Decrypting on the Receiving Side

On Bob's device, the whole thing runs backward:

  • Remove the outer SRTP layer for the connection to the SFU.
  • Reorder and reassemble the packets into one encrypted frame.
  • Read the SFrame key ID and counter from the header.
  • Look up the key for that sender.
  • Verify the authentication tag.
  • Decrypt the encoded frame.
  • Hand it to the codec decoder.
  • Render the video or play the audio.

Two things can go wrong here. The first is a frame that shows up with a key ID Bob doesn't have yet. That happens around every join and every rotation, and the right move is to hold the frame briefly while the key catches up. The second is a frame whose tag doesn't verify. That one gets dropped, no exceptions. Pass it on, and you're feeding the decoder corrupted data, and you've also just given anyone on the path a way to inject their own frames into the call.

Rekeying When Someone Joins or Leaves

The third question. Does the key have to change every time someone joins or leaves? Yes.

Getting this right is one of the harder parts of the whole design. It's also where many E2EE claims fall short. The goal is easy to state. Someone who joins shouldn't be able to decrypt what was said before they arrived, and someone who leaves shouldn't be able to decrypt what's said after they leave.

Event What has to happen Why
Participant joins Move to a new epoch, or ratchet sender keys forward before sharing them The new participant can't decrypt earlier media
Participant leaves Generate fresh keys the removed participant never receives The removed participant can't decrypt later media
Periodic rotation Ratchet keys and delete the old values Limits what a later key compromise exposes
Suspected compromise Run a fresh authenticated group update Restores protection for future media

Joins and leaves look like the same problem, but they aren't. If you're working with sender keys, the difference matters:

  • On a join, each sender can hash its current key forward and hand the newcomer only the result. Hashes are one-way, so the newcomer can't work backward to earlier keys or earlier media.
  • On a leave, hashing forward gets you nothing. The person who left knows the current key and can compute the same hash you can. The remaining participants need entirely new key material, sent over channels the removed device can't read.

MLS handles both with an epoch change, which is a large part of why it exists.

There's one more wrinkle. Video frames depend on earlier frames. So a receiver that gets a new key partway through a group of pictures can decrypt every frame that follows, and still can't decode any of them until the next keyframe comes along. SFrame recommends aligning key rotation with keyframe generation for this reason, and Encoded Transform gives the receiving transform a way to request a keyframe when needed.

A demo showing the receiver's video freeze to gray noise right after a sender key rotation, before recovering at the next keyframe

You can see this happen with the code above. Rotate the sender to a new key and the receiver freezes, since its worker drops every incoming frame as an unknown key ID. Handing it the new key doesn't bring the picture back on its own, because the frames it can now decrypt are delta frames that depend on ones it threw away. It has to request a key frame as soon as the key arrives, and the picture recovers when that key frame comes through.

What Does End-to-End Encryption Protect?

E2EE protects the media and nothing else.

Protected Not protected by E2EE on its own
Audio, video, and screen-share content Who called whom
Media against anyone tapping the network When the call happened and how long it lasted
Media against a TURN server or SFU without the keys IP addresses and network provider
Integrity of every encrypted frame Packet sizes, timing, and bitrate
Earlier and later media around membership changes, when rekeying is done right A participant's own compromised device
Recorded traffic against later key compromise, when forward secrecy is in place A participant recording their screen
Media from anyone who was never a participant Availability, including denial of service

There is a really important part here that can catch people out. SFrame doesn't provide per-sender authentication against a malicious participant. That means that E2EE doesn't tell you which participant produced a frame. SFrame uses symmetric keys, so anyone who can decrypt Alice's frames holds Alice's key, and anyone who holds Alice's key can build a frame that verifies as hers. Bob can be Alice if he is malicious enough.

That's a narrower gap than knowing whether the person on screen is who they claim to be at all; encryption doesn't verify the human in the call, only the frames.

If you need per-sender authentication, say, to make a recording attributable in a legal setting, you need signatures or some other authentication on top.

Metadata exposure can also depend on the architecture you picked earlier. In a direct call, Alice and Bob learn each other's IP addresses, which for some users is a real privacy problem. Relay the call through a server and each participant's address is hidden from the other, though the relay still sees both. WhatsApp offers exactly this as an option, routing calls through its servers to hide IPs while keeping the media end-to-end encrypted.

Why Turning on E2EE Turns off Server-Side Features

Any feature where the server needs to understand the media must see the media, and E2EE is a promise the server never has. This means E2EE can miss out on:

  • Cloud recording.
  • Live transcription and captions.
  • Server-side AI assistants and meeting summaries.
  • Content moderation.
  • Audio mixing for large calls.
  • Video compositing and transcoding, including for livestream output.
  • Telephone and SIP or H.323 gateways.

For each one, there are three ways to respond:

  • Move the work onto each participant's device, which is fine for transcription on a laptop and hard for compositing a hundred-person livestream.
  • Add a recording or transcription bot as an explicit cryptographic participant in the call, so it receives keys the same way a person would.
  • Give a server or service the media keys.

The second option can stay E2EE in a strict protocol sense, but the bot is now an endpoint that can read and keep the entire call, and every participant should be able to see that it's there. The third option gives up the guarantee. Which one is right depends on the product, but it should be a deliberate decision that's visible to users, not something buried in the architecture.

  • Zoom's E2EE mode is a useful reference for how the tradeoff plays out. Enabling it turns off cloud recording, live transcription, AI features, streaming, and several other cloud functions, and it prevents phone and SIP or H.323 participants from joining. Zoom also shows a security code that participants can read out to each other to confirm they share the same cryptographic meeting state.
  • Microsoft Teams scopes its E2EE to the media in supported one-to-one calls, and turning it on turns off recording, transcription, participant addition, transfers, and other server-assisted features.

Building It Yourself vs. Turning It On

If you're going to build this yourself, the pieces are all public:

  • WebRTC security architecture, RFC 8827, for ICE, DTLS, certificate fingerprints, SRTP, and the rules for peer authentication.
  • SFrame, RFC 9605, for frame-level media encryption that an SFU can route without reading.
  • MLS, RFC 9420, for authenticated group key agreement and rotation at scale.
  • WebRTC Encoded Transform, the W3C API that hands you encoded frames between the encoder and the packetizer. The worker-based RTCRtpScriptTransform has been supported in Chrome, Firefox, and Safari since late 2025, though the spec is still a Working Draft.

Encryption handles a frame in microseconds, and the worker code above is most of what's needed. But there is a lot of other work to make video calls secure:

  • Knowing who is legitimately in the call, which means binding keys to authenticated devices and giving users a way to check.
  • Making sure the server can't substitute keys or silently add a participant.
  • Rotating keys correctly on every join, leave, and timer, and coordinating that with keyframes so video doesn't freeze.
  • Leaving the SFU enough metadata to route media well while giving it nothing it can read.
  • Deciding, feature by feature, what moves to the device, what becomes a visible bot, and what you give up.

A product that gets all of those right can tell its users that nobody but the participants can see or hear the call. Skip any of them, and you're encrypting media in transit, which is worth having, but it's a different concept from the one people want.

E2EE in Stream Video

Or you can skip most of that. Stream Video now supports end-to-end encryption, and the client-side setup is a few lines.

import { EncryptionManager, StreamVideoClient } from "@stream-io/video-react-sdk";

const client = new StreamVideoClient({ apiKey, user, tokenProvider });
const call = client.call("e2ee", "my-call-id");

if (EncryptionManager.isSupported()) {
  const e2ee = await EncryptionManager.create(call.currentUserId);
  e2ee.setSharedKey(0, key); // a 16-byte AES key your app produced
  call.setE2EEManager(e2ee); // must happen before join()
  await call.getOrCreate();
  await call.join();
}

You create an encryption manager, give it a key, attach it to the call before you join, and the SDK encrypts every outgoing frame on the device and decrypts every incoming frame on the way in. Stream's SFU only ever forwards ciphertext.

You still own the keys. Look back at the sections on identifying devices and agreeing on keys. If Stream generated or carried your keys, Stream would be a party to the call, and the whole point is that it isn't. So your app generates the keys, moves them between participants over a channel you control, and decides when to rotate.

Rotation maps directly onto the join-and-leave rules from earlier. Every key carries an index from 0 to 255. To rotate, set a new key under the next index and distribute it. Outgoing media switches to it straight away; the old key stays around long enough to decrypt frames still in flight, and then you drop it:

e2ee.setSharedKey(nextIndex, newKey);
// once in-flight frames have drained
e2ee.removeSharedKey(currentIndex);

Per-participant keys work the same way with setKey(userId, keyIndex, key), plus removeAllKeys(userId) when someone leaves, so you stop decrypting their media and, once you rotate, they can't decrypt yours.

Failures on the receiving side show up as events:

  • e2ee.missing_key is the frame tagged with a key index you don't hold yet.
  • e2ee.decryption_failed is the frame that didn't verify.
  • e2ee.decryption_resumed tells you a track recovered.
  • useE2eeEnabled() gives you the lock badge, indicating whether encryption is actually in effect for the call, as confirmed by Stream when you joined, not just what you asked for.

Encryption is set per call type in the dashboard, fixed when the call is created, and never partial. Once a call is encrypted, every participant sends encrypted media. And server-side features, such as recording, transcription, or HLS broadcasting, that require plaintext don't work over an encrypted call.

The E2EE guide has the full API, including per-participant keys and the event list.

Just Like Text, But With More Moving Parts

In text, E2EE is Alice's message, Bob's key, and a server that carries something it can't read. Video is fundamentally the same. Every frame gets encrypted after the codec, the server routes on headers it's allowed to see, and the keys change whenever someone joins or leaves. But you have a lot more to consider around encryption, from knowing who's really on the call to giving up server-side features that require plaintext.

If you want that without building the pipeline, Stream Video has it. The SDK handles the encryption path through the SFU and back. You handle the keys, which is the one part that has to stay in your hands. The E2EE guide covers call types, keys, rotation, and events, and you can start with Stream Video today.