Why video calls need a different encryption approach
End-to-end encryption for real-time communication poses challenges that differ from those of text messaging. Text-based E2EE systems like WhatsApp and iMessage are designed for long-lived, asynchronous, low-bandwidth conversations. Video calls are short-lived and synchronous, and they consume far more bandwidth.
These differences rule out some established techniques. Apple’s iMessage, for instance, encrypts a message separately for each recipient in a group chat. That approach would be impractical for video: encrypting every frame multiple times would saturate upload capacity and slow down clients. Media must be encrypted once and decrypted by each participant, without exposing the contents to the SFU or anyone else on the data plane.
There is also the matter of key lifecycles. In chat, participants need to decrypt messages that arrived while they were offline. For calls, that concern does not arise. However, a real-time system must handle participants joining and leaving mid-session, which affects key management.
MLS as the basis for group key agreement
Messaging Layer Security (MLS) is an IETF-standardized protocol for group key exchange that provides continuous group key agreement. This means it delivers two important properties: perfect forward secrecy and post-compromise security. In practical terms, a user who joins a call cannot decrypt media from before their arrival, and a user who is removed cannot decrypt media from after their departure.
Before MLS, achieving these properties typically meant adapting the Signal protocol, which is more of a living specification than a formalized standard. MLS offers a standardized alternative, and multiple high-quality implementations now exist. For this project, we used the open-source Rust implementation from Phoenix R&D and Cryspen.
Our implementation needed to be client-side and work within the browser. We compiled the MLS client to WASM so it could run in-browser and decrypt video streams. If you are building a native desktop or mobile application, other MLS implementations are available in your preferred language.
Notably, no changes to the SFU were required. The Cloudflare Realtime SFU operates as a pure data plane and does not inspect or care whether the media it forwards is encrypted.
Architecture of the encrypted Orange Meets client
Orange Meets consists of three entities:
- The user participant who connects to both the server and the SFU.
- The Orange Meets Server, a Cloudflare Worker handling room coordination logic: which user is in which room and the state of the room. Room changes are broadcast to all participants.
- The Cloudflare Realtime SFU, which receives audio and video from each participant and forwards it to everyone else, using lossy UDP transport where dropped frames are not retransmitted.
The naive approach to encrypting this setup would be to have participants agree on a single symmetric key at the start of the call and encrypt every frame with it. That works against a passive observer like the SFU, but it fails on security grounds. A key negotiated once cannot be revoked; a participant removed from the call would keep the key and could continue decrypting, and a late joiner would receive the key used for earlier media. MLS avoids these problems.
Encrypting the streams in the browser
Our encryption pipeline works through a web worker written in Rust. The worker accepts a WebRTC video stream, processes it frame by frame, and performs MLS encryption on each frame:
group.create_message(
&self.mls_provider,
self.my_signing_keys.as_ref()?,
frame,
)
To route the stream through the worker, we take advantage of newer WebRTC API features that allow adding a transform step to a stream:
const senderStreams = sender.createEncodedStreams()
const { readable, writable } = senderStreams
this.worker.postMessage(
{
type: 'encryptStream',
in: readable,
out: writable,
},
[readable, writable]
)
The same transform is applied for decryption on the receiving side:
const receiverStreams = receiver.createEncodedStreams()
const { readable, writable } = receiverStreams
this.worker.postMessage(
{
type: 'decryptStream',
in: readable,
out: writable,
},
[readable, writable]
)
Once both audio and video streams have been wired through this encryption and decryption layer, the bulk of the work is done.
Handling codec-specific behavior
Although the browser is sending and receiving encrypted media, it still treats the stream as ordinary video. This creates a problem: the browser’s depacketization logic expects codec-specific bytes in certain positions, and cryptographically random data causes regular errors in parsing.
This exact issue was documented by Discord engineers in their DAVE protocol. For VP8, the codec we use by default, the fix is straightforward: the first 1–10 bytes of each packet are sent in the clear.
fn split_vp8_header(frame: &[u8]) -> Option<(&[u8], &[u8])> {
// If this is a keyframe, keep 10 bytes unencrypted. Otherwise, 1 is enough
let is_keyframe = frame[0] >> 7 == 0;
let unencrypted_prefix_size = if is_keyframe { 10 } else { 1 };
frame.split_at_checked(unencrypted_prefix_size)
}
Those leading bytes contain only versioning information, keyframe indicators, constants, and the video dimensions. They reveal nothing sensitive, so leaving them unencrypted allows the browser to correctly parse the stream without compromising security.
With that adjustment, the stream encryption is complete. What remains is the problem of onboarding new participants into an encrypted room. We will cover that next.
Getting Users Into the Group
In an end-to-end encrypted call, a joining participant needs cryptographic material before they can decrypt anything. The obvious approach is to use MLS external proposals: the server registers as an external sender — able to send administrative messages to the group but not receive any — and submits a new user's key package as an External Add proposal. A group member then commits that proposal, sending the joiner a Welcome message with everything needed to participate.

That works, but it forces the server to understand MLS. Since the goal was to keep all cryptography client-side, the Orange Meets implementation instead uses a designated committer algorithm. A joining user sends their key material to one existing member — the designated committer — who constructs and broadcasts the Add message to the rest of the group. Likewise, when someone leaves, the designated committer builds and sends the Remove message. The server stays a dumb broadcast channel; the whole state machine is about 300 lines of Rust.

This design exploits the fact that a video call is synchronous. The same trick would not work for an asynchronous text group chat, since the designated committer could be offline indefinitely while others wait for them to process joins and leaves.
Formally Checking the Protocol
The designated committer approach introduces edge cases worth scrutinizing:
- How is a single designated committer guaranteed? It is defined as the alive user with the smallest index in the shared MLS group state.
- What if the designated committer leaves? The next user takes over, inheriting any pending Adds and Removes.
- Could a lagging user wrongly believe they are the designated committer? No — they must first be certain that all earlier eligible committers have disconnected.
To be sure, the team formally modeled the algorithm in TLA+. The model checker did surface real bugs — for instance, if the designated committer died mid-add, the protocol would never recover. The fix was to break MLS operations into smaller steps and enforce strict local message ordering (a Welcome always precedes its corresponding Add).
The full PlusCal program and lessons learned are published. As with any bounded check, this only proves correctness for groups of up to five users. Still, since there are only two distinct roles — committer and regular member — any flaw would likely surface with a small group.
Defeating Monster-in-the-Middle
A malicious service provider could swap users' key packages for its own and, working with a compromised SFU, decrypt, view, and re-encrypt video streams undetected. The defense is the same one DAVE uses: a safety number shown in the corner of the call, derived from the group's cryptographic state. If everyone confirms the number out-of-band — say, over Signal — then no key material has been silently replaced.
Reading the number aloud during the call is not provably secure. That is an in-band check, and an attacker who controls both the app server and SFU could synthesize convincing audio and video of a user reciting it. Out-of-band verification is the only rigorous option if your threat model includes real-time deep-fakes.
Remaining Gaps
Two improvements stand out. First, a malicious server can still serve malicious JavaScript to users — the classic JavaScript Cryptography Problem. The proposed fix is the Web Application Manifest Consistency, Integrity, and Transparency standard, which extends the Code Verify approach: sites commit to the JavaScript they serve, and a third party maintains an auditable log.
Second, out-of-band authentication could be streamlined with an identity provider. Using OpenPubkey, a provider signs a user's cryptographic material; other participants verify that signature before use. Transparency logs would again help ensure no signatures were issued secretly.
The E2EE-enabled build is live at e2ee.orange.cloudflare.dev, with the source code on GitHub. The implementation — a WASM service worker doing MLS group management plus per-stream crypto — stayed entirely client-side, and the formal modeling caught the kind of corner cases that tend to hide in distributed protocols.



