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.

Hop-by-hop SRTP keys versus one end-to-end payload key A left-to-right path from sender to forwarding server to receiver. Two separate SRTP hop keys terminate at the server, which sees RTP headers only. A single end-to-end payload key spans the whole path, held only by the two endpoints. Sender encoder β†’ transform holds payload key holds hop key A Forwarding server reads RTP headers rewrites seq / ssrc payload opaque Receiver transform β†’ decoder holds payload key holds hop key B SRTP hop key A β€” terminates at the server SRTP hop key B β€” fresh per subscriber end-to-end payload key β€” distributed out of band, never sent to the server compromising the server yields headers, sizes and timing β€” not media
DTLS-SRTP keys stop at the forwarding server; the frame-level key does not.

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.

Frame byte layout before and after the encrypt transform Two horizontal byte strips. The first shows the encoder output as a codec header followed by plaintext payload. The second shows the transformed frame: unchanged codec header, AES-GCM ciphertext, a sixteen byte tag, a twelve byte IV and a one byte key id. before transform β€” encoder output codec header encoded payload (plaintext) AES-GCM over the payload, header as AAD after transform β€” handed to the packetizer codec header 10 / 3 / 1 B AES-GCM ciphertext tag 16 B IV 12 B kid 1 B server parses this keyframe + descriptor opaque to every hop no key on the server stripped on receive kid enables overlap +29 B per frame β‰ˆ 7 kbps on 30 fps video, β‰ˆ 12 kbps on 50 fps Opus
Only the middle of the frame changes; the codec header the packetizer reads is byte-identical.

Reproduction Steps & Debugging Log Patterns

  1. 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.
  2. 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.
  3. Deliberately encrypt one byte too many by setting CLEAR.delta to 2, and watch the packetizer emit frames the depacketizer rejects.
  4. Rotate the key by publishing a new kid while keeping the old key resident, and verify no frames are dropped across the switch.
  5. Poll getStats() at 1 s intervals and diff framesReceived against framesDecoded β€” 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.

Diagnosing a stalled decoder behind an encrypted transform A left-to-right decision tree. Starting from framesDecoded stuck at zero, it branches on whether packets are arriving, whether the worker reports decrypt failures, and whether the codec header was left in the clear, ending in four distinct diagnoses. framesDecoded stuck at 0 packetsReceived rising? no not an E2EE fault ICE or DTLS path first yes worker logs decryptFailure? yes key id mismatch rotation raced the media no codec header left in clear? no bad payload descriptor pliCount climbs forever yes receiver transform missing new receiver after renegotiation
Four failures produce the same black frame; three counters separate them.

Common Implementation Mistakes

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.