End-to-End Encryption

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Every WebRTC call is encrypted. What the browser does not promise is that only the people in the call can read it. In a peer-to-peer call the two ends of the DTLS-SRTP session are the two browsers, so the encryption happens to be end to end. The moment a media server (an SFU) sits in the middle, the server terminates DTLS-SRTP, sees every frame in the clear and re-encrypts it towards each receiver. This page explains that gap, shows how to close it with the Encoded Transform API and per-frame encryption, and is honest about what end-to-end encryption in a browser still cannot do.

It was written against the W3C WebRTC Encoded Transform Working Draft of 2026-06-25, the SFrame standard RFC 9605, the WebRTC security architecture RFC 8827 and the Messaging Layer Security protocol RFC 9420. The examples were run on Chrome 155. All facts were checked on 2026-10-24. The topologies are on Topologies: Mesh, SFU and MCU, the DTLS-SRTP handshake is in WebRTC Architecture, and the general threat model is on Security.

What it is and who uses it

Only the videoconference needs it. Huddle in mesh mode (up to four participants) is already end to end, because every media stream travels directly between two browsers over DTLS-SRTP. Huddle in SFU mode, with mediasoup or LiveKit, is not: the operator of the SFU can read, record or alter the call. For a company that hosts its own SFU this is often acceptable. For a hosted service, a medical consultation or a call between journalists it is not, and the room owner turns on end-to-end encryption.

Chatterbox does not use WebRTC media. Its end-to-end story is a message-level scheme such as OMEMO (see OMEMO Encryption). Pixel Arena sends game state that the server must read to be authoritative, so end-to-end encryption would defeat the design. The only game feature that could use it is a private voice chat between friends, which is a Huddle-style call.

Hop by hop versus end to end

The terms describe who holds the keys.

  • Hop by hop. Each link has its own key. A browser encrypts for the SFU, the SFU decrypts, may inspect, record or re-route the frame, and encrypts again for each receiver. DTLS-SRTP (RFC 5764) gives exactly this and nothing more. The browser authenticates the DTLS certificate against the fingerprint in the SDP, and the SDP arrives through signalling, so the fingerprint is only as trustworthy as the signalling server.

  • End to end. The sender encrypts the media frame with a key only the participants hold. The SFU still receives and forwards packets, because it only needs RTP headers and a few clear bytes of the payload, but it cannot read the frame. DTLS-SRTP still runs underneath, so the frame is encrypted twice on every hop.

flowchart LR subgraph HOP["Hop by hop (DTLS-SRTP only)"] direction LR A1["Alice browser"] -->|"SRTP key 1"| S1["SFU: decrypts and re-encrypts, can read frames"] S1 -->|"SRTP key 2"| B1["Bob browser"] S1 -->|"SRTP key 3"| C1["Carol browser"] end subgraph E2E["End to end (DTLS-SRTP plus per-frame encryption)"] direction LR A2["Alice browser: encrypts frame with sender key"] -->|"SRTP key 1 around frame ciphertext"| S2["SFU: routes only, sees ciphertext"] S2 -->|"SRTP key 2 around frame ciphertext"| B2["Bob browser: decrypts frame"] S2 -->|"SRTP key 3 around frame ciphertext"| C2["Carol browser: decrypts frame"] end

This is why the W3C and IETF documents treat the media server as an additional party. RFC 8827 section 9.1 lets browsers verify the peer certificate fingerprint out of band, for example by reading a short authentication string aloud, and section 7.4.1 describes identity assertions that bind a fingerprint to a user. Both only help when the other end of the DTLS session is the person you mean. With an SFU the other end is the server, so they do not help.

The Encoded Transform API

The browser exposes the encoded frame (after the codec, before RTP packetisation on the sender; after RTP reassembly, before the decoder on the receiver). A script can read and replace the frame bytes. This is the Encoded Transform API, the successor of Chrome’s "insertable streams for media".

The standard API

  • RTCRtpSender.transform and RTCRtpReceiver.transform accept a transform object.

  • new RTCRtpScriptTransform(worker, options, transfer) runs the transform in a Worker. In the worker, the rtctransform event carries event.transformer, with readable and writable streams of encoded frames and the options object you passed. You pipe readable through a TransformStream into writable.

  • Each frame is an RTCEncodedVideoFrame or RTCEncodedAudioFrame. Both have data (an ArrayBuffer you may replace) and getMetadata(), which returns the SSRC (synchronizationSource), payload type, RTP timestamp, contributing sources and, for video, width, height, frameId and the spatial and temporal layer indexes. Video frames have a type of key or delta.

  • The transformer also has generateKeyFrame(rid) and sendKeyFrameRequest(). A receiver that joins mid-call or receives a new key needs a key frame encrypted with that key, so a sender usually calls generateKeyFrame() after a key change.

  • The writable stream has no backpressure, so a slow transform makes frames queue. Keep the transform fast and drop frames you cannot process.

  • Set the transform before the first frame flows (right after addTrack() on the sender, in the track event on the receiver), or the first frames bypass it.

Browser status

RTCRtpScriptTransform and RTCRtpSender.transform are in Chrome and Edge 141, Firefox 117 and Safari 15.4, and MDN marks the feature Baseline 2025, newly available since October 2025. Chrome’s earlier, non-standard RTCRtpSender.createEncodedStreams() (Chrome 86) still exists in Chrome 155 but is legacy: Firefox and Safari never implemented it, and new code should not use it. Before Chrome 141, E2EE libraries kept two code paths for that reason.

The SFrame interfaces

SFrame (Secure Frame, RFC 9605, a Proposed Standard) defines the frame encryption format, so that different implementations interoperate and the SFU can rely on a known header. The Encoded Transform draft has been moving from a single SFrameTransform interface to dedicated classes. The draft of 2026-06-25 defines:

  • RTCRtpSFrameEncryptor and RTCRtpSFrameDecryptor, assigned to sender.transform and receiver.transform, with options { cipherSuite, type } where type is per-frame (default) or per-packet;

  • SFrameEncryptorStream and SFrameDecryptorStream for use as stream transforms in a window or dedicated worker;

  • setEncryptionKey(key, keyId) on encryptors and addDecryptionKey(key, keyId) and removeDecryptionKey(keyId) on decryptors, all returning promises, with CryptoKey keys and key IDs from 0 to 264-1;

  • eight cipher suites, such as AES_128_GCM_SHA256_128, AES_256_GCM_SHA512_128 and AES_128_CTR_HMAC_SHA256_80;

  • an error event on the decryptor with errorType of syntax, keyID or authentication;

  • a type of sframe in the WorkerAndParameters argument of RTCRtpScriptTransform, so that a worker can still observe SFrame frames.

None of this is usable in production yet. A feature detection in Chrome 155 reports RTCRtpScriptTransform as a function and RTCRtpSFrameEncryptor, RTCRtpSFrameDecryptor and SFrameTransform as undefined. MDN’s compatibility data has no entry for them, and Firefox’s tracking bug for SFrameTransform is open with no target release and the comment that the spec is still being brought to a stable point. The practical consequence is that every E2EE implementation today runs the cipher itself inside an RTCRtpScriptTransform worker, with WebCrypto. The following examples do that, and keep the frame layout simple. When the SFrame classes ship, the encryptor and decryptor replace the worker, and the key management code stays the same.

The SFrame format

RFC 9605 defines what a standard implementation puts on the wire, and it is worth following even when you write the worker yourself.

  • Header. A configuration byte with flags and either the key ID (KID) and counter (CTR) values in the byte itself (0 to 7) or their lengths, followed by the values as big-endian integers when they are larger. The header is authenticated as associated data but not encrypted, so the SFU can read the KID and the frame counter.

  • Ciphertext and tag. The frame payload after the clear codec bytes, plus an authentication tag.

  • Keys. A base key is expanded with HKDF into an AEAD key and a nonce salt, bound to the KID and the cipher suite. A (KID, CTR) pair must never be reused, and a base key must have only one sender.

  • Cipher suites. AES-CTR with HMAC-SHA-256 and a 32, 64 or 80 bit tag, AES-128-GCM with a 128 bit tag, and AES-256-GCM with SHA-512. Different media may use different suites, for example a short tag for audio.

  • Ratchet. A sender may derive the next key by hashing the current one, which locks out receivers who join later but not receivers who left. Removing a participant needs a fresh key from a new distribution round.

  • Weaknesses stated by the RFC. Observers see KIDs and counters, so they learn who is sending and how much. Any holder of a symmetric key can forge media from another sender, and short tags make forgery cheaper.

Not every codec byte can be encrypted. The packetiser and the SFU read the first bytes of the payload (VP8 uses 10 bytes on key frames and 3 on delta frames, Opus 1 byte, H.264 and AV1 need NAL or OBU headers), so those stay in the clear and are authenticated as associated data. Encrypted frames are also larger and not compressible, which is why the example below adds 29 bytes per frame.

Example 1: an encrypt and decrypt worker

The worker is the whole cipher. Each frame becomes: clear codec header, AES-GCM ciphertext with its 16-byte tag, a 12-byte IV made from the SSRC and a counter, and a 1-byte key ID. The header and key ID are authenticated as associated data. Frames without a usable key are dropped, never forwarded in the clear. One file handles both directions, selected by the operation option.

// e2ee-worker.js: runs one RTCRtpScriptTransform for Huddle.
// Frame layout on the wire: [clear header][AES-GCM ciphertext + tag][12-byte IV][1-byte key id]
const keys = new Map(); // keyId -> CryptoKey
let sendKeyId = 0;
let counter = 0n;
const stats = { encrypted: 0, decrypted: 0, dropped: 0, byKeyId: {} };

onmessage = (event) => {
  const msg = event.data;
  if (msg.type === 'key') {
    keys.set(msg.keyId, msg.key);
    if (msg.useForSending) sendKeyId = msg.keyId;
  } else if (msg.type === 'removeKey') {
    keys.delete(msg.keyId);
  } else if (msg.type === 'stats') {
    postMessage({ type: 'stats', stats });
  }
};

// Bytes that stay in the clear so the SFU can still packetise and detect key frames.
// VP8: 10 bytes on key frames, 3 on delta frames. Opus: 1 byte.
function clearBytes(frame) {
  if (frame.type === 'key') return 10;
  if (frame.type === 'delta') return 3;
  return 1;
}

function makeIv(ssrc) {
  const iv = new Uint8Array(12);
  const view = new DataView(iv.buffer);
  view.setUint32(0, ssrc);
  view.setBigUint64(4, counter++);
  return iv;
}

async function encryptFrame(frame, controller) {
  const key = keys.get(sendKeyId);
  if (!key) return; // no key yet: drop rather than leak plaintext
  const data = new Uint8Array(frame.data);
  const n = Math.min(clearBytes(frame), data.length);
  const iv = makeIv(frame.getMetadata().synchronizationSource);
  const aad = new Uint8Array(n + 1);
  aad.set(data.subarray(0, n));
  aad[n] = sendKeyId;
  const body = new Uint8Array(await crypto.subtle.encrypt(
    { name: 'AES-GCM', iv, additionalData: aad }, key, data.subarray(n)));
  const out = new Uint8Array(n + body.length + 12 + 1);
  out.set(data.subarray(0, n), 0);
  out.set(body, n);
  out.set(iv, n + body.length);
  out[out.length - 1] = sendKeyId;
  frame.data = out.buffer;
  stats.encrypted++;
  controller.enqueue(frame);
}

async function decryptFrame(frame, controller) {
  const data = new Uint8Array(frame.data);
  const n = clearBytes(frame);
  if (data.length < n + 16 + 12 + 1) { stats.dropped++; return; }
  const keyId = data[data.length - 1];
  const key = keys.get(keyId);
  if (!key) { stats.dropped++; return; }
  const iv = data.subarray(data.length - 13, data.length - 1);
  const aad = new Uint8Array(n + 1);
  aad.set(data.subarray(0, n));
  aad[n] = keyId;
  try {
    const plain = new Uint8Array(await crypto.subtle.decrypt(
      { name: 'AES-GCM', iv, additionalData: aad }, key, data.subarray(n, data.length - 13)));
    const out = new Uint8Array(n + plain.length);
    out.set(data.subarray(0, n), 0);
    out.set(plain, n);
    frame.data = out.buffer;
    stats.decrypted++;
    stats.byKeyId[keyId] = (stats.byKeyId[keyId] || 0) + 1;
    controller.enqueue(frame);
  } catch {
    stats.dropped++; // authentication failed: wrong key or tampered frame
  }
}

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

Example 2: attaching the transform in Huddle

The page creates one worker per sender and per receiver and posts the keys to it. Pinning the video codec to VP8 keeps the number of clear bytes known; a real client chooses the clear-byte count per negotiated codec.

// huddle-e2ee.js: attach end-to-end encryption to a Huddle peer connection.
export function protect(sender) {
  const worker = new Worker('/e2ee-worker.js');
  sender.transform = new RTCRtpScriptTransform(worker, { operation: 'encrypt' });
  return worker;
}

export function unprotect(receiver) {
  const worker = new Worker('/e2ee-worker.js');
  receiver.transform = new RTCRtpScriptTransform(worker, { operation: 'decrypt' });
  return worker;
}

export function preferVp8(transceiver) {
  const codecs = RTCRtpReceiver.getCapabilities('video').codecs;
  const vp8 = codecs.filter((c) => c.mimeType === 'video/VP8');
  transceiver.setCodecPreferences(vp8);
}

export function workerStats(worker) {
  return new Promise((resolve) => {
    const listener = (e) => { if (e.data.type === 'stats') { worker.removeEventListener('message', listener); resolve(e.data.stats); } };
    worker.addEventListener('message', listener);
    worker.postMessage({ type: 'stats' });
  });
}

A caller wires it like this, after the signalling layer has delivered the sender keys (see Example 3):

import { protect, unprotect, preferVp8 } from '/huddle-e2ee.js';

const pc = new RTCPeerConnection({ iceServers });
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
const senders = stream.getTracks().map((track) => pc.addTrack(track, stream));
preferVp8(pc.getTransceivers().find((t) => t.sender.track.kind === 'video'));

for (const sender of senders) {
  const worker = protect(sender);
  worker.postMessage({ type: 'key', keyId: 1, key: mySenderKey, useForSending: true });
}

pc.ontrack = (event) => {
  const worker = unprotect(event.receiver);
  // keysFromSignalling maps a stream id to the unwrapped sender keys delivered over the WebSocket.
  for (const [keyId, key] of keysFromSignalling.get(event.streams[0].id)) {
    worker.postMessage({ type: 'key', keyId, key });
  }
  remoteVideo.srcObject = event.streams[0];
};

The same code was run in a loopback call between two peer connections in headless Chrome 155 with a fake camera and microphone. The results show what end-to-end encryption does to a receiver:

  • with the matching key, all 98 received video frames were decoded and the 640 pixel wide video played;

  • with a wrong key, every frame failed AES-GCM authentication and was dropped, so the receiver decoded 0 frames;

  • with no receiver transform (what a receiver without the key, or an SFU with a recorder, sees), 98 frames arrived but only 3 decoded, because the payload was ciphertext;

  • with the sender rotating from key 1 to key 2 mid-call, the receiver decrypted 48 frames with key 1 and 50 with key 2 without a break, because both keys were installed before the switch.

Key distribution

Encryption is the easy half. The hard half is getting a key to exactly the current participants, and changing it when they change.

  • One shared key per room. Simplest. A room secret is placed in the invitation link fragment (after #, so it is never sent to a server) or derived from a passphrase. The SFU never sees it. The weakness is that everyone with the link can decrypt, a leaver still holds the key, and rotation means a new link. LiveKit’s built-in key provider works this way.

  • Sender keys. Each participant generates its own key and sends it to every other participant over a pairwise encrypted channel. This is the SFrame model. A leaver costs one new key per remaining participant.

  • Group key agreement with MLS. Messaging Layer Security (RFC 9420) agrees a group key for 2 to thousands of members with forward secrecy and post-compromise security, and an exporter secret can seed SFrame keys. It is the right tool for large rooms, at the price of a client library and a delivery service.

  • Signalling as the carrier. In all cases the keys travel through the signalling WebSocket, which the server operator controls, so the keys must be wrapped for each recipient by a key the server cannot know.

Whoever controls signalling can substitute a public key and read everything, unless participants compare a fingerprint out of band. The example below derives a six digit safety number from both public keys, which two people read to each other.

Example 3: sender keys with ECDH and WebCrypto

Each participant creates a non-extractable ECDH P-256 key pair and publishes the public half. To give a sender key to another participant, the key owner derives a pairwise AES-GCM key from its private key and the recipient’s public key, and wraps the sender key with it. The wrapped key goes through the signalling server, which cannot unwrap it. The same file seals Huddle chat messages for the data channel.

// e2ee-keys.js: key helpers for Huddle's end-to-end encryption.
const ECDH = { name: 'ECDH', namedCurve: 'P-256' };

export async function newIdentity() {
  const pair = await crypto.subtle.generateKey(ECDH, false, ['deriveKey']);
  // The public half must be exportable even when the private half is not.
  const publicRaw = new Uint8Array(await crypto.subtle.exportKey('raw', pair.publicKey));
  return { privateKey: pair.privateKey, publicRaw };
}

export async function newSenderKey() {
  return crypto.subtle.generateKey({ name: 'AES-GCM', length: 128 }, true, ['encrypt', 'decrypt']);
}

async function pairwiseKey(privateKey, theirPublicRaw) {
  const theirPublic = await crypto.subtle.importKey('raw', theirPublicRaw, ECDH, false, []);
  return crypto.subtle.deriveKey({ name: 'ECDH', public: theirPublic }, privateKey,
    { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}

// Wrap a sender key for one participant. The result travels through the signalling server.
export async function wrapSenderKey(senderKey, myPrivate, theirPublicRaw) {
  const kek = await pairwiseKey(myPrivate, theirPublicRaw);
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const raw = await crypto.subtle.exportKey('raw', senderKey);
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, raw));
  return { iv: Array.from(iv), ct: Array.from(ct) };
}

export async function unwrapSenderKey(wrapped, myPrivate, senderPublicRaw) {
  const kek = await pairwiseKey(myPrivate, senderPublicRaw);
  const raw = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: new Uint8Array(wrapped.iv) }, kek,
    new Uint8Array(wrapped.ct));
  // Import as non-extractable: it only needs to encrypt and decrypt frames.
  return crypto.subtle.importKey('raw', raw, 'AES-GCM', false, ['encrypt', 'decrypt']);
}

// Six-digit code both users read aloud to detect a man in the middle on the signalling channel.
export async function safetyNumber(publicA, publicB) {
  const [first, second] = [publicA, publicB].sort((a, b) => Array.from(a).join().localeCompare(Array.from(b).join()));
  const digest = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array([...first, ...second])));
  const value = new DataView(digest.buffer).getUint32(0) % 1000000;
  return String(value).padStart(6, '0');
}

// Data-channel messages (Huddle chat) use the same sender key.
export async function sealJson(key, value) {
  const iv = crypto.getRandomValues(new Uint8Array(12));
  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key,
    new TextEncoder().encode(JSON.stringify(value))));
  const out = new Uint8Array(12 + ct.length);
  out.set(iv); out.set(ct, 12);
  return out.buffer;
}

export async function openJson(key, buffer) {
  const bytes = new Uint8Array(buffer);
  const plain = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: bytes.subarray(0, 12) }, key, bytes.subarray(12));
  return JSON.parse(new TextDecoder().decode(plain));
}

A run in headless Chrome showed that Bob unwraps Alice’s key and decrypts frames with it, that a third identity cannot unwrap it (the AES-GCM authentication fails), and that both sides compute the same six digit safety number regardless of the order of the two keys.

Joining and leaving

Key changes are where designs fail.

  1. A participant joins. The newcomer publishes its public key. Every existing participant wraps its current sender key for the newcomer and sends it. The newcomer starts decrypting after it receives the first key frame encrypted with each key. If the sender keys were ratcheted before the join, the newcomer cannot decrypt the past, which is what you want. Call generateKeyFrame() or send a key frame request so the newcomer does not wait.

  2. A participant leaves. Everyone must stop trusting the leaver’s access. Each remaining participant creates a new sender key with a higher key ID, distributes it to the remaining participants only, switches sending to it after a short delay, and removes the old key after the last frame using it has gone. Ratcheting alone does not protect against the leaver, because the leaver can compute the same ratchet.

  3. A device reconnects. Resend the current keys: a reconnecting client has lost its worker state. Keep the key ID in the frame so receivers pick the right key.

The example frame carries a one-byte key ID, which allows 256 key generations per sender. A long-lived room needs a longer key ID, as SFrame’s variable-length KID provides.

End-to-end encrypted data channels

Data channels (see Data Channels) are DTLS-encrypted SCTP. In mesh mode they are already end to end. In SFU mode, mediasoup data producers pass through the server, and LiveKit carries data packets through the SFU, so the same gap exists. There is no encoded transform for data channels: the application encrypts each message before send(). sealJson() and openJson() in Example 3 prepend a random 12-byte IV to an AES-GCM ciphertext, so a message of {"type":"chat","text":"hello huddle"} crossed the data channel as 65 bytes and decrypted to the original object. Never reuse an IV with the same key, and give each sender its own key if messages could race. LiveKit’s documentation lists data channel encryption as part of its E2EE feature, enabled through the encryption room option.

Feature detection and fallback

Because the SFrame classes are not shipped, code that wants to use them when they appear, and otherwise run the worker, should detect the capability and keep the key management independent of the cipher.

export function e2eeSupport() {
  if (typeof RTCRtpSFrameEncryptor === 'function' && typeof RTCRtpSFrameDecryptor === 'function') {
    return 'sframe-native';
  }
  if (typeof RTCRtpScriptTransform === 'function' && 'transform' in RTCRtpSender.prototype) {
    return 'script-transform';
  }
  if (typeof RTCRtpSender.prototype.createEncodedStreams === 'function') {
    return 'legacy-insertable-streams';
  }
  return 'none';
}

export async function attachNativeSframe(rtpObject, isReceiver, key, keyId) {
  const cipherSuite = 'AES_128_GCM_SHA256_128';
  if (!isReceiver) {
    const encryptor = new RTCRtpSFrameEncryptor({ cipherSuite });
    await encryptor.setEncryptionKey(key, keyId);
    rtpObject.transform = encryptor;
  } else {
    const decryptor = new RTCRtpSFrameDecryptor({ cipherSuite });
    await decryptor.addDecryptionKey(key, keyId);
    decryptor.addEventListener('error', (e) => console.warn('SFrame error', e.errorType));
    rtpObject.transform = decryptor;
  }
}

The Chrome 155 run printed script-transform for this detection (RTCRtpScriptTransform present, SFrame classes absent). The native branch follows the interface definitions of the draft; no browser ships them yet, so it is a template for the day they do.

How LiveKit and Jitsi do it

LiveKit enables end-to-end encryption per room. Audio, video and data channel messages are encrypted in the client SDK; signalling and API calls stay protected by TLS only and the server can read them. LiveKit does not store or deliver keys: the application generates, stores and distributes them, typically sending a shared key with the access token. In the JavaScript SDK, the built-in ExternalE2EEKeyProvider holds one shared key (set with setKey()), a worker from livekit-client/e2ee-worker does the frame work, and room.setE2EEEnabled(true) turns it on. Per-participant keys, rotation, MLS or Megolm need a custom key provider that extends BaseKeyProvider, with ratchetKey() for rotation and an onKeyRatcheted callback. SDKs exist for JavaScript, iOS, Android, Flutter, React Native, Python, Rust, Node.js and C++. See LiveKit for the SFU itself.

Jitsi’s design is documented in lib-jitsi-meet. Each participant generates a random key for its own media, the keys are exchanged over an Olm channel, and the encryption runs in the encoded transform path. Its documentation says the model follows SFrame with slight changes, that the feature is a work in progress, and that it works only in browsers with insertable streams support. Jitsi’s security page lists the scope: audio, video and screen sharing, but not chat or polls. See Janus and Jitsi.

Limits: what end-to-end encryption does not give you

  • The server still sees metadata. Who joined, when, from which IP address, how much each participant sends, SSRCs, the SFrame KID and counter and the clear codec bytes. Active speaker detection with RFC 6464 audio levels still works only if the audio level header extension stays unencrypted, which leaks when someone is speaking.

  • Server features that need media stop working. Server-side recording, transcription, live streaming to RTMP, and mixing (MCU) cannot read ciphertext. A recording has to be made by a participant-side recorder (see Recording and Media Processing) that holds a key. Jitsi’s design paper of 2021 also listed telephone dial-in as unavailable in its end-to-end mode.

  • Simulcast and SVC need care. The SFU chooses layers from RTP headers and header extensions, so those must stay in the clear, each layer is encrypted as its own frame stream, and a receiver switching layers needs a key frame.

  • The code comes from the server. A browser downloads the JavaScript, the worker and the key handling from the same operator who runs the SFU. A hostile operator can ship a client that leaks the keys. Real protection needs signed or reproducible clients, a native app, or a browser extension.

  • Identity is still a problem. SFrame gives confidentiality and integrity against outsiders, but any key holder can forge another sender’s frames. Authentication of who holds which key is the application’s job (safety numbers, a trusted directory, MLS credentials).

  • Cost. Extra CPU per frame, 29 bytes per frame in the example, and keyframe delays after key changes.

  • Browsers. Chrome 141, Firefox 117 and Safari 15.4 and later support the script transform; the SFrame classes are not shipped (checked on Chrome 155).

Common errors and pitfalls

  • Black video after enabling encryption. The clear-byte count does not match the codec. The decoder gets a corrupted payload and shows nothing. Fix the header size, or pin the codec.

  • Frames dropped silently. A receiver without the right key drops every frame, so it looks like a network problem. Surface the worker’s counters in the UI (the loopback test above read them with a stats message).

  • Frames leaking in the clear. A sender transform assigned after the first frames flowed, or a worker that forwards frames when no key is present. Drop frames until a key exists, as the example does.

  • IV reuse. Two frames encrypted with one key and one IV break AES-GCM. The example derives the IV from the SSRC and a monotonically increasing counter. A counter that resets on reconnect while the key stays must change the key.

  • Key frames missing after a join. A newcomer sees nothing until the next key frame. Ask for one.

  • Transform on the wrong object. sender.transform is for senders, receiver.transform for receivers. Assigning a transform that already belongs to another sender or receiver throws InvalidStateError.

  • One worker per track is wasteful in a large mesh. Keep the per-frame work small and reuse a worker where a design allows it, or the main thread of a low-end device stalls.

  • createEncodedStreams() in new code. It is Chrome-only and legacy.

Security notes

  • The WebCrypto keys in the examples are non-extractable on the receiving side and on the identity private keys. The sender key must be extractable because it is sent to others; keep it in memory only.

  • Never put sender keys in URL query strings, logs or analytics. A URL fragment is not sent to the server but is still stored in browser history.

  • Use a fresh key per room and per session, and rotate on every leave.

  • Verify identities out of band, because the signalling server is the attacker in the model that matters here.

  • Do not roll your own cryptography in production. The examples show the structure; use a maintained SFrame implementation (the LiveKit and Jitsi libraries, or the browser’s native one once shipped) and review it.

History and specification status

  • Chrome 86 (2020) shipped "insertable streams for media" with createEncodedStreams(), which drove the first E2EE implementations (Jitsi among them).

  • The W3C WebRTC Working Group standardised the API as WebRTC Encoded Transform with RTCRtpScriptTransform, shipped in Safari 15.4 (2022), Firefox 117 (2023) and Chrome 141 (2025).

  • SFrame became RFC 9605 (2024) at the IETF. The Encoded Transform draft dated 2026-06-25 is a Working Draft on the Recommendation track and now defines the encryptor and decryptor classes.

  • MLS became RFC 9420 (2023).