Summary
With end-to-end encryption enabled and a single key at index 0 shared by both
participants, a video track published by Firefox is never decrypted by a Chromium
receiver. The receiver logs missing key at index 241, then 147, then 96 — values
that cannot be a key index, since only index 0 exists.
The opposite direction is fine: a track published by Chromium is decrypted by Firefox with
no decryption failure at all. So this is not key distribution, and not our key provider.
Environment
|
|
livekit-client |
2.22.3 |
livekit-server |
v1.13.6 (self-hosted, Docker) |
| Publisher |
Firefox 153 (Playwright build firefox-1538) |
| Receiver |
Chromium (Playwright 1.62.1, chromium-1234) |
| Key provider |
ExternalE2EEKeyProvider, setKey(<base64>), one key, index 0, identical on both sides |
| Room options |
e2ee: { keyProvider, worker }, adaptiveStream: true, dynacast: true, videoCaptureDefaults.resolution = h360. No publishDefaults.videoCodec is set. |
Reproduced three times, same signature each time, driven by Playwright (one Firefox
context, one Chromium context, same room, same key).
Interop matrix (measured, one session each, 25 s of media)
| Publisher → Receiver |
Video packets / frames decoded on the receiver |
Key failures |
| Chromium → Chromium |
works |
0 |
| Chromium → Firefox |
23 / 6 |
0 |
| Firefox → Firefox |
401 / 354 and 337 / 300 both ways |
0 |
| Firefox → Chromium |
10 / 0 |
15+ (missing key at index 241, 192, 128) |
Firefox → Firefox is the important row: Gecko decrypts what Gecko publishes, with no
failure at all. So frames published by Firefox are encrypted and do carry a
trailer that a Gecko receiver finds. Only a Chromium receiver cannot parse them.
What the receiver sees
setting codec on cryptor to {codec: vp8} — so the receiving cryptor is on VP8;
- then, repeatedly,
missing key at index 241 / 147 / 96;
- no frame is ever decoded; the participant's video stays black.
Why we think the frame layout, not the key, is at fault
In livekit-client.e2ee.worker, the receiver reads the key index from the last byte of
the frame:
const data = new Uint8Array(encodedFrame.data);
const keyIndex = data[encodedFrame.data.byteLength - 1];
and the number of unencrypted header bytes depends on the detected codec
(getUnencryptedBytes): VP8 uses {key: 10, delta: 3}, VP9 uses 0, H.264/H.265 go
through NALU processing, AV1 is rejected outright. When codec detection fails, it falls
back to this.videoCodec.
Values like 241/147/96 are what you would read if the last byte were payload rather than
the packet trailer — i.e. if the sender's frame did not carry the trailer where the
receiver expects it, or if the two sides disagreed on the frame layout.
What we measured, so you don't have to ask
- Codec is not the explanation. We read
RTCRtpSender/RTCRtpReceiver stats on both
sides: both publish video/VP8, and the Chromium receiver's cryptor logs
setting codec on cryptor to {codec: vp8}. So the unencrypted-prefix length
(UNENCRYPTED_BYTES for VP8) should match on both ends.
- Encryption is definitely happening on the Firefox publisher. Firefox → Firefox
decodes 300+ frames with zero decryption failures.
- So the remaining suspect is the frame trailer as written on the Gecko publish path
(RTCRtpScriptTransform) versus as read on the Chromium receive path: either its
position, or whether writing into frame.data survives on Gecko.
Two generations of the browser API, and a recently reworked trailer
Two facts that may or may not be related, but that we could not ignore:
- The two browsers are on two different generations of the encoded-transform API.
Chromium shipped the original createEncodedStreams in 2020; the spec then changed, and
Firefox shipped the standardised RTCRtpScriptTransform in 2023 (Safari in 2022).
Chromium only shipped RTCRtpScriptTransform itself in Chrome 141, while keeping the
legacy entry point working. So a Firefox publisher and a Chromium publisher do not
necessarily hand the worker a frame with the same conventions, even for the same codec.
- The trailer this SDK writes was reworked shortly before the version we run:
2.20.0 — Rename PacketTrailer to FrameMetadata, 2.20.1 — Add support for
user_data frame metadata trailer type. We are on 2.22.3, so both sides of our test
use the same trailer code; we mention it only because the symptom is exactly "the last
byte is not where the receiver expects it".
We have no evidence tying either fact to the failure. They are offered as context, not as
a diagnosis.
Pinning the codec does not help (measured 2026-09-14)
We tried the workaround we were about to ask you about, on both participants:
publishDefaults.videoCodec |
Result on the Chromium receiver |
| unset (default) |
missing key at index 241, 147, 96 — 0 frames decoded |
"vp8" |
missing key at index 241 — 0 frames decoded |
"h264" |
missing key at index 155 — 0 frames decoded |
"vp9" |
missing key at index 241 — 0 frames decoded |
Four remarks:
- pinning VP8 changes nothing because VP8 was already what both sides negotiated — we
report it only so nobody repeats the experiment;
- H.264 takes a different path in the worker (
getUnencryptedBytes → NALU processing
instead of the VP8 {key: 10, delta: 3} prefix), and fails identically, only with a
different absurd key index;
- VP9 is the decisive one. In your own worker,
getUnencryptedBytes returns
{ unencryptedBytes: 0, requiresNALUProcessing: false } for VP9 — the entire frame is
encrypted, no codec-specific parsing, no NALU handling, nothing skipped on either side.
This is the shortest possible path through the encryption logic. It fails exactly the
same way, down to the same key index (241) as the unset-codec run;
- with the receiver-side codec branch reduced to the smallest case there is and the
failure unchanged, the receiver's per-codec unencrypted-byte logic can be ruled out as
a class, not just for one codec. Whatever breaks interoperability is upstream of that
branch entirely.
So the failure does not depend on codec detection or on how many header bytes are skipped,
in any of the four configurations we tried. That leaves the trailer itself — its presence
or its position in the frame as it arrives on the Chromium receive path when the frame was
written by Firefox's RTCRtpScriptTransform path.
What we ruled out
- Not key distribution: the same key string is set on both sides, and Chromium → Firefox
works with that same key.
- Not our capability gate:
isE2EESupported() returns true on Firefox, and encryption
does activate — Firefox publishes encrypted frames, they simply cannot be read.
- Not ICE/transport: the session connects, media flows, and audio behaves; only the
decryption of Firefox-published video fails.
Why it matters to us
We run teleconsultation for a health platform. A patient joining from Firefox appears to be
in the room and believes they are seen, while the practitioner on Chrome sees nothing.
Because that failure is silent on the patient's side, we now block room entry entirely
for Gecko-based browsers rather than warn about it — the room does not open, and there is
no way to override it. We detect the family by the frame-insertion mechanism
(createEncodedStreams vs RTCRtpScriptTransform), not by user agent. This is
deliberately broader than the defect: Firefox → Firefox works, but we cannot know which
browser the other participant will use before the session starts.
We would rather not ship that restriction.
Question
Is Firefox-as-publisher with E2EE expected to interoperate with Chromium receivers in
2.22.x?
If it is, we would appreciate a pointer on where to look next: forcing the codec on both
sides does not help (see above), the key is shared and works in the other direction, and
the trailer code is the same build on both ends. Is there a known difference in how the
frame buffer behaves when written from the RTCRtpScriptTransform path?
If it is not expected to work, we would suggest saying so in the E2EE documentation —
isE2EESupported() returns true on Firefox and encryption does activate, so nothing at
the API level warns an integrator that the session will be one-way.
We checked first: docs.livekit.io/transport/encryption (overview),
docs.livekit.io/transport/encryption/start (the JS getting-started guide), and
docs.livekit.io/transport/media/advanced (the codec reference) — none of them mention
Firefox, Gecko, RTCRtpScriptTransform, createEncodedStreams, or any cross-browser
limitation under E2EE. So we believe this genuinely isn't documented anywhere yet, rather
than us having missed it.
One unrelated discrepancy noticed along the way, worth a separate line either way: the
codec reference lists AV1 (including SVC) as a supported publishing codec, while
getUnencryptedBytes in the E2EE worker throws "av1 is not yet supported for end to end encryption" the moment E2EE is on. A reader following the codec page into an E2EE room
would hit that at runtime with no warning beforehand.
Summary
With end-to-end encryption enabled and a single key at index 0 shared by both
participants, a video track published by Firefox is never decrypted by a Chromium
receiver. The receiver logs
missing key at index 241, then147, then96— valuesthat cannot be a key index, since only index 0 exists.
The opposite direction is fine: a track published by Chromium is decrypted by Firefox with
no decryption failure at all. So this is not key distribution, and not our key provider.
Environment
livekit-clientlivekit-serverfirefox-1538)chromium-1234)ExternalE2EEKeyProvider,setKey(<base64>), one key, index 0, identical on both sidese2ee: { keyProvider, worker },adaptiveStream: true,dynacast: true,videoCaptureDefaults.resolution = h360. NopublishDefaults.videoCodecis set.Reproduced three times, same signature each time, driven by Playwright (one Firefox
context, one Chromium context, same room, same key).
Interop matrix (measured, one session each, 25 s of media)
missing key at index 241,192,128)Firefox → Firefox is the important row: Gecko decrypts what Gecko publishes, with no
failure at all. So frames published by Firefox are encrypted and do carry a
trailer that a Gecko receiver finds. Only a Chromium receiver cannot parse them.
What the receiver sees
setting codec on cryptor to {codec: vp8}— so the receiving cryptor is on VP8;missing key at index 241/147/96;Why we think the frame layout, not the key, is at fault
In
livekit-client.e2ee.worker, the receiver reads the key index from the last byte ofthe frame:
and the number of unencrypted header bytes depends on the detected codec
(
getUnencryptedBytes): VP8 uses{key: 10, delta: 3}, VP9 uses0, H.264/H.265 gothrough NALU processing, AV1 is rejected outright. When codec detection fails, it falls
back to
this.videoCodec.Values like 241/147/96 are what you would read if the last byte were payload rather than
the packet trailer — i.e. if the sender's frame did not carry the trailer where the
receiver expects it, or if the two sides disagreed on the frame layout.
What we measured, so you don't have to ask
RTCRtpSender/RTCRtpReceiverstats on bothsides: both publish
video/VP8, and the Chromium receiver's cryptor logssetting codec on cryptor to {codec: vp8}. So the unencrypted-prefix length(
UNENCRYPTED_BYTESfor VP8) should match on both ends.decodes 300+ frames with zero decryption failures.
(
RTCRtpScriptTransform) versus as read on the Chromium receive path: either itsposition, or whether writing into
frame.datasurvives on Gecko.Two generations of the browser API, and a recently reworked trailer
Two facts that may or may not be related, but that we could not ignore:
Chromium shipped the original
createEncodedStreamsin 2020; the spec then changed, andFirefox shipped the standardised
RTCRtpScriptTransformin 2023 (Safari in 2022).Chromium only shipped
RTCRtpScriptTransformitself in Chrome 141, while keeping thelegacy entry point working. So a Firefox publisher and a Chromium publisher do not
necessarily hand the worker a frame with the same conventions, even for the same codec.
2.20.0— Rename PacketTrailer to FrameMetadata,2.20.1— Add support foruser_dataframe metadata trailer type. We are on2.22.3, so both sides of our testuse the same trailer code; we mention it only because the symptom is exactly "the last
byte is not where the receiver expects it".
We have no evidence tying either fact to the failure. They are offered as context, not as
a diagnosis.
Pinning the codec does not help (measured 2026-09-14)
We tried the workaround we were about to ask you about, on both participants:
publishDefaults.videoCodecmissing key at index 241,147,96— 0 frames decoded"vp8"missing key at index 241— 0 frames decoded"h264"missing key at index 155— 0 frames decoded"vp9"missing key at index 241— 0 frames decodedFour remarks:
report it only so nobody repeats the experiment;
getUnencryptedBytes→ NALU processinginstead of the VP8
{key: 10, delta: 3}prefix), and fails identically, only with adifferent absurd key index;
getUnencryptedBytesreturns{ unencryptedBytes: 0, requiresNALUProcessing: false }for VP9 — the entire frame isencrypted, no codec-specific parsing, no NALU handling, nothing skipped on either side.
This is the shortest possible path through the encryption logic. It fails exactly the
same way, down to the same key index (
241) as the unset-codec run;failure unchanged, the receiver's per-codec unencrypted-byte logic can be ruled out as
a class, not just for one codec. Whatever breaks interoperability is upstream of that
branch entirely.
So the failure does not depend on codec detection or on how many header bytes are skipped,
in any of the four configurations we tried. That leaves the trailer itself — its presence
or its position in the frame as it arrives on the Chromium receive path when the frame was
written by Firefox's
RTCRtpScriptTransformpath.What we ruled out
works with that same key.
isE2EESupported()returnstrueon Firefox, and encryptiondoes activate — Firefox publishes encrypted frames, they simply cannot be read.
decryption of Firefox-published video fails.
Why it matters to us
We run teleconsultation for a health platform. A patient joining from Firefox appears to be
in the room and believes they are seen, while the practitioner on Chrome sees nothing.
Because that failure is silent on the patient's side, we now block room entry entirely
for Gecko-based browsers rather than warn about it — the room does not open, and there is
no way to override it. We detect the family by the frame-insertion mechanism
(
createEncodedStreamsvsRTCRtpScriptTransform), not by user agent. This isdeliberately broader than the defect: Firefox → Firefox works, but we cannot know which
browser the other participant will use before the session starts.
We would rather not ship that restriction.
Question
Is Firefox-as-publisher with E2EE expected to interoperate with Chromium receivers in
2.22.x?
If it is, we would appreciate a pointer on where to look next: forcing the codec on both
sides does not help (see above), the key is shared and works in the other direction, and
the trailer code is the same build on both ends. Is there a known difference in how the
frame buffer behaves when written from the
RTCRtpScriptTransformpath?If it is not expected to work, we would suggest saying so in the E2EE documentation —
isE2EESupported()returnstrueon Firefox and encryption does activate, so nothing atthe API level warns an integrator that the session will be one-way.
We checked first:
docs.livekit.io/transport/encryption(overview),docs.livekit.io/transport/encryption/start(the JS getting-started guide), anddocs.livekit.io/transport/media/advanced(the codec reference) — none of them mentionFirefox, Gecko,
RTCRtpScriptTransform,createEncodedStreams, or any cross-browserlimitation under E2EE. So we believe this genuinely isn't documented anywhere yet, rather
than us having missed it.
One unrelated discrepancy noticed along the way, worth a separate line either way: the
codec reference lists AV1 (including SVC) as a supported publishing codec, while
getUnencryptedBytesin the E2EE worker throws"av1 is not yet supported for end to end encryption"the moment E2EE is on. A reader following the codec page into an E2EE roomwould hit that at runtime with no warning beforehand.