End-to-End Encryption with Insertable Streams
SRTP protects media between an endpoint and whatever it is talking to, and in a conference that is a forwarding server, not the other participant. This guide is part of the DTLS-SRTP Security & Encryption guide, and it answers one question: how do you wrap encoded frames in a second cipher layer, using RTCRtpScriptTransform, so the media server can still route packets while holding no key that reveals a pixel or a sample.
Context & Trade-offs
The default WebRTC security model is hop-by-hop. A forwarding server terminates DTLS with each participant, decrypts inbound SRTP, and re-encrypts outbound SRTP under a different key per subscriber. That is not an accident of implementation β it is required, because the server rewrites sequence numbers, timestamps and SSRCs as it switches layers, and it cannot rewrite what it cannot read. Anyone who compromises that process, or subpoenas the operator, reads the call.
Insertable Streams moves a second cipher inside the endpoint. The transform sits between the video encoder and the RTP packetizer on the way out, and between the depacketizer and the decoder on the way in. What travels is a frame whose codec header is intact and whose payload bytes are ciphertext under a key the server never receives. The forwarding path keeps working because everything a Selective Forwarding Unit Design actually needs β RTP sequence number, timestamp, SSRC, marker bit, the simulcast and dependency-descriptor header extensions, and the first few bytes of codec payload that mark keyframe boundaries β is still in the clear.
The costs are concrete. Each frame grows by a fixed trailer: a 16-byte AES-GCM authentication tag, a 12-byte initialisation vector and a 1-byte key identifier, so 29 bytes. At 30 fps that is roughly 7 kbps per video stream and, because audio frames arrive every 20 ms, about 12 kbps on an Opus stream that is only running at 24β32 kbps β a 40% relative overhead on audio that is worth flattening by shortening the IV to a 4-byte counter once you have a stable design. CPU is cheaper than most people fear: AES-GCM with hardware acceleration encrypts a 6 kB keyframe in tens of microseconds, and the measurable cost is the plumbing, not the cipher. Expect 0.2β0.8 ms of added one-way latency per direction from crossing into a worker and back, and 1β3% of one core per encrypted stream on desktop, against the 1β3 ms a server spends forwarding a packet.
What you give up is every server-side operation that needs pixels. Transcoding is gone, so mixing topologies are off the table. Server-side thumbnails, speech-to-text and moderation are gone. So is Compositing Multi-Party Recordings Server-Side β a recording must now be produced by a headless participant that has been admitted to the key group like any other member. Layer selection survives untouched, because the server picks layers from header extensions rather than payload, which is exactly why Rewriting RTP Header Extensions When Forwarding must be implemented correctly before you switch encryption on.
Key distribution is the part that is genuinely hard, and the part the browser gives you nothing for. A per-room symmetric key delivered by your application server is easy and only defends against a compromised media plane. A sender-key scheme, where each participant generates its own key and distributes it over an already-authenticated channel, plus a ratchet forward on every join and a full rekey on every leave, is the usual production shape. Carry those messages over a Data Channels & SCTP transport rather than the signaling socket so the key material never transits a server you have excluded from your threat model.
Minimal Runnable Implementation
// --- main thread ---------------------------------------------------------
const worker = new Worker('/e2ee-worker.js');
// encodedInsertableStreams is the Chrome <110 legacy flag; ignored where
// RTCRtpScriptTransform exists (Chrome 110+, Safari 15.4+, Firefox 117+).
const pc = new RTCPeerConnection({ encodedInsertableStreams: true, iceServers });
const videoSender = pc.getSenders().find((s) => s.track?.kind === 'video');
// Attach BEFORE setLocalDescription(). A transform installed later lets the
// first few encoded frames escape to the wire in plaintext.
videoSender.transform = new RTCRtpScriptTransform(worker, { op: 'encrypt', kid: 3 });
pc.ontrack = ({ receiver }) => {
// Every renegotiation hands you a NEW receiver object with no transform.
receiver.transform = new RTCRtpScriptTransform(worker, { op: 'decrypt' });
};
// --- e2ee-worker.js ------------------------------------------------------
// Bytes the RTP packetizer and the server must still be able to parse.
// VP8: 10 on keyframes (payload + start-code), 3 on delta frames; Opus: 1.
const CLEAR = { key: 10, delta: 3, audio: 1 };
const TRAILER = 13; // 12-byte IV + 1-byte key id, appended after the tag
let counter = 0n; // monotonic; combined with SSRC it makes IVs unique
self.onrtctransform = async (event) => {
const { readable, writable, options } = event.transformer;
const step = options.op === 'encrypt' ? encrypt : decrypt;
await readable.pipeThrough(new TransformStream({ transform: step })).pipeTo(writable);
// Audio frames have no .type property; video frames are 'key' or 'delta'.
const clearBytes = (frame) => (frame.type ? CLEAR[frame.type] : CLEAR.audio);
async function encrypt(frame, controller) {
const view = new Uint8Array(frame.data);
const clear = clearBytes(frame);
const iv = new Uint8Array(12);
const dv = new DataView(iv.buffer);
dv.setBigUint64(0, counter++); // never random, never reused
dv.setUint32(8, frame.getMetadata().synchronizationSource);
const ct = new Uint8Array(await crypto.subtle.encrypt(
// The header is authenticated as AAD but left readable, so a server that
// flips a keyframe bit is detected without ever needing the key.
{ name: 'AES-GCM', iv, additionalData: view.subarray(0, clear), tagLength: 128 },
await keyring.get(options.kid), view.subarray(clear)));
const out = new Uint8Array(clear + ct.length + TRAILER);
out.set(view.subarray(0, clear), 0); // codec header, untouched
out.set(ct, clear); // ciphertext || 16-byte tag
out.set(iv, clear + ct.length);
out[out.length - 1] = options.kid; // lets rotation overlap
frame.data = out.buffer;
controller.enqueue(frame);
}
async function decrypt(frame, controller) {
const view = new Uint8Array(frame.data);
const clear = clearBytes(frame);
const end = view.length - TRAILER;
try {
const pt = new Uint8Array(await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: view.subarray(end, view.length - 1),
additionalData: view.subarray(0, clear), tagLength: 128 },
await keyring.get(view[view.length - 1]), view.subarray(clear, end)));
const out = new Uint8Array(clear + pt.length);
out.set(view.subarray(0, clear), 0);
out.set(pt, clear);
frame.data = out.buffer;
controller.enqueue(frame);
} catch {
// Drop it. A failed tag means a stale key or a tampered frame β never
// forward the ciphertext to the decoder, it will wedge on garbage.
self.postMessage({ decryptFailure: frame.getMetadata().rtpTimestamp });
}
}
};
The layout that code produces is what makes the whole scheme work with an unmodified server: a readable prefix, an opaque middle, and a trailer the receiving transform strips before the decoder ever sees the frame.
Reproduction Steps & Debugging Log Patterns
- Bring up a two-party call through your forwarding server with the transform attached on the sender only, and confirm the receiver shows a black frame β that proves the second layer is actually applied rather than silently skipped.
- Attach the receive transform and confirm video recovers within one keyframe interval; if it needs more than 2 s, your server is not honouring the keyframe request the decoder raised.
- Deliberately encrypt one byte too many by setting
CLEAR.deltato2, and watch the packetizer emit frames the depacketizer rejects. - Rotate the key by publishing a new
kidwhile keeping the old key resident, and verify no frames are dropped across the switch. - Poll
getStats()at 1 s intervals and diffframesReceivedagainstframesDecodedβ see Interpreting getStats() for Congestion Signals for the polling harness.
A healthy encrypted stream and a broken one look almost identical at the transport layer, which is the whole difficulty:
// healthy β inbound-rtp, 1 s apart
// packetsReceived 1482 β 1621 framesReceived 28 β 58 framesDecoded 28 β 58
// pliCount 1 β 1 keyFramesDecoded 1 β 1
// encrypted codec header β transport is perfect, nothing ever decodes
// packetsReceived 1495 β 1638 framesReceived 0 β 0 framesDecoded 0 β 0
// pliCount 4 β 11 (decoder keeps asking, every keyframe is unusable)
// stale key after rotation β frames arrive and are dropped by the transform
// packetsReceived 1490 β 1630 framesReceived 27 β 57 framesDecoded 27 β 27
// worker: {decryptFailure: 3221225472} repeating at frame rate
Chromeβs chrome://webrtc-internals will not show you any of the transformβs behaviour, so the workerβs own counters are your only instrument. Emit decryptFailure with the frameβs rtpTimestamp and a rolling failure rate; a rate that spikes and returns to zero is a rotation race, while one that starts at 100% and stays there is a key-agreement failure.
Common Implementation Mistakes
- Encrypting the codec header. The packetizer runs after your transform and parses those first bytes to build the payload descriptor and mark keyframes. Encrypt them and the frame is structurally invalid before it reaches the network; the server forwards it, the receiverβs depacketizer rejects it, and
pliCountclimbs without bound. - Reusing an IV under one key. AES-GCM nonce reuse leaks the XOR of two plaintexts and lets an attacker forge tags. A random 12-byte IV collides after roughly 2^48 frames, which sounds safe until you reuse a room key for weeks; a monotonic counter concatenated with the SSRC never collides and costs nothing.
- Shipping keys through the server you distrust. Delivering a room key over your signaling socket in a JSON message gives the operator the key. Either bind key exchange to a channel the server cannot read, or accept that you have built defence in depth, not end-to-end encryption, and say so.
- Rotating with a single key slot. Frames in flight at the moment of rotation are still encrypted under the old key and arrive up to a few hundred milliseconds later. Keep the previous key resident for 2 s and select by the
kidbyte, or you drop a visible burst on every join. - Attaching the transform only on the initial receiver. Renegotiation β a track replacement, an added screen share, an SVC reconfiguration β produces a new
RTCRtpReceiverwith an emptytransformslot. Re-attach in everyontrack, not once at setup. - Doing the crypto on the main thread. The deprecated
createEncodedStreams()path can run in window scope, and at 30 fps it competes with layout and paint. Always terminate the streams inside a dedicated worker.
FAQ
What can the media server still learn about an encrypted call?
Everything outside the payload: SSRCs, packet sizes, frame rate, keyframe cadence, the simulcast and dependency-descriptor extensions, and who is sending when. Frame sizes alone reveal talking patterns and, on screen share, coarse activity. Frame-level encryption protects content, not metadata; if the traffic pattern matters, add padding at the transform.
Does this break simulcast or SVC?
No. Each simulcast encoding produces its own frames and each is encrypted independently, and layer selection reads header extensions the transform never touches. What breaks is anything that needs to re-encode β an SVC layer the server wanted to drop mid-frame, or a fallback to server-side transcoding for a legacy endpoint.
Is RTCRtpScriptTransform available everywhere?
Chrome 110+, Safari 15.4+ and Firefox 117+ ship it. Older Chrome exposes only createEncodedStreams() behind encodedInsertableStreams, and no mobile WebView older than Chrome 110 supports either. Feature-detect and refuse to join an encrypted room rather than silently falling back to plaintext.
Related: return to DTLS-SRTP Security & Encryption, and read Verifying DTLS Fingerprints to Prevent MITM for the hop-level trust model this layer sits on top of, or Debugging DTLS Handshake Failures when the transport itself never comes up.