Skip to content

End-to-end encryption ​

WebRTC media is always encrypted hop by hop with DTLS-SRTP. That protects it on the wire, but a media server in the middle, like an SFU, ends that encryption and can read every frame. (A TURN server is fine: it only passes the encrypted packets along.) E2EE adds a second layer on the encoded frame, before it is split into packets, so a relay only ever sees ciphertext.

The hard part is not the encryption. It is making a browser and three native SDKs produce exactly the same bytes, so that any client can decrypt any other.

PlatformMechanismCode
WebInsertable Streams: createEncodedStreams() (Chrome) or RTCRtpScriptTransform (Safari, Firefox), in a workere2ee.ts, encryptionWorker.ts, frameCrypto.ts
AndroidFrameCryptor + FrameCryptorKeyProvider from webrtc-sdkE2eeManager.kt, FrameCryptors.kt
iOS, macOSRTCFrameCryptor + RTCFrameCryptorKeyProvider from webrtc-sdkFrameEncryption.swift
Windowslibwebrtc's frame cryptor, through the C shimshim_peer.cpp

The native FrameCryptor uses the LiveKit frame format. The web client implements the same format byte for byte, and that is what makes Web, Android, iOS, macOS and Windows interoperate.

The frame format ​

text
┌──────────────────────┬───────────────────────────────────┬────────────┬──────┬──────────┐
│ unencrypted header   │ AES-128-GCM ciphertext + 16B tag  │ IV (12 B)  │ 0x0C │ keyIndex │
└──────────────────────┴───────────────────────────────────┴────────────┴──────┴──────────┘
  additional data (AAD)                                       per frame    IV len  1 byte

The first bytes of each frame stay readable, because the packetizer and the decoder need them:

CodecUnencrypted headerWhy
VP810 bytes on key frames, 3 on delta framesThe payload header, so frame type and size can still be parsed
H264Up to and including the first slice NAL header + 1 byteNAL structure and SPS/PPS stay readable. The rest is RBSP-escaped so it never contains a start code.
Opus1 byte, the TOCThe frame configuration

The header isn't encrypted, but it is authenticated as AES-GCM additional data, so changing it makes decryption fail.

Try it. This box runs the web client's real code on a sample frame:

Encrypt a frame with the web client's own code

This runs encryptStream() and decryptStream() from web/src/e2ee.ts in your browser, the same code the web client runs in its worker.

Frame
Key material, as sent in "encryption key"
From the encoder · 0 bytes
After encryption, to the packetizer · 0 bytes
  • Unencrypted header. The packetizer and decoder still read it. Covered by the GCM tag as additional data.
  • Ciphertext: AES-128-GCM of the rest of the frame, same length as the plain data.
  • GCM tag, 16 bytes. If it does not match, the receiver drops the frame.
  • IV, 12 bytes: SSRC, RTP timestamp and a send counter. It changes on every frame.
  • IV length, always 12.
  • Key index, always 0 here.

A frame that can't be encrypted or decrypted (no key yet, wrong key, broken frame) is dropped. It is never sent or decoded in plain form. Empty frames, like Opus silence, pass through untouched, as the native cryptor does.

The key ​

Only the 32 bytes of key material travel, base64 in the encryption key message. Each side derives the AES key locally. In a 1:1 call the offerer generates the material and sends it before the offer.

The key provider options must be identical everywhere. If any of them differ, the peers derive different keys or write a different trailer, and the other side can't decrypt:

OptionValue
Shared key modetrue
Ratchet salt"LKFrameEncryptionKey"
Ratchet window size0
Uncrypted magic bytesnone
Failure tolerance-1
Key ring size16
Discard frame when cryptor not readyfalse
Key derivationPBKDF2
Key index0

Codec choice ​

When E2EE is on, every platform puts VP8 first in the video codec preferences (setCodecPreferences) before the offer or the answer. Its header layout is the simplest and the most consistent across implementations. iOS puts VP8 first on every call anyway, see iOS screen sharing.

The web client also reads the negotiated SDP to map payload types to codecs (parseCodecMap), because getMetadata().mimeType isn't available in every browser.

Cryptor lifecycle ​

Both peers must turn E2EE on. If only one does, the other side gets frames it can't decode or decrypt, and the call stays black and silent. E2EE is chosen in the lobby and can't be toggled during a call.

In group calls the SFU forwards ciphertext without knowing it, and the room creator's key material becomes the room key. See Group calls (SFU).

Good enough for a demo

The key material goes through the signaling server in plain form. A real app should use a key agreement (for example ECDH) or a passphrase shared out of band, and rotate the key when someone leaves a group call.