Call Encryption
Audio, video and screen share in Floodilka calls are end-to-end encrypted: frames are encrypted in the sender’s browser and decrypted only in the browsers of the other participants. The media server (LiveKit SFU) forwards ciphertext it cannot read.
Threat model
Section titled “Threat model”| Who | Can they see the media |
|---|---|
| Passive network observer | No — DTLS-SRTP to the SFU sits on top of E2EE |
| LiveKit SFU (including third-party hosting) | No — it only ever receives encrypted frames |
| Compromised media server | No — it has no key; the key never goes into the LiveKit token |
| Floodilka backend and gateway | Yes — they generate and distribute the room key |
| A call participant | Yes — that is the point of a shared room key |
The practical win: the media server can be hosted anywhere — a third-party provider, another jurisdiction, rented hardware — without widening the set of parties able to listen to a call.
How it works
Section titled “How it works”- The backend generates 32 random bytes (
crypto.randomBytes) per room and stores them in Redis undervoice:room:e2ee:guild:{guild_id}:{channel_id}orvoice:room:e2ee:dm:{channel_id}. Creation is atomic (SET … NX GET), so every participant of a room is guaranteed the same key. - The key is returned to the client with the voice token and is never placed in the LiveKit token — neither in a grant nor in metadata. The SFU verifies that token, so anything inside it is readable by the SFU; the key must not go there.
- The gateway relays the key in
VOICE_SERVER_UPDATE(e2ee_keyande2ee_required) and never interprets it. - The client decodes the base64 into raw bytes, hands them to
ExternalE2EEKeyProviderfromlivekit-client(raw bytes mean HKDF; a string would mean PBKDF2 over a passphrase), enables encryption, and only then connects to the room. - The key lives as long as the room itself and is dropped on the
room_finishedwebhook or an rtc region switch. It deliberately has no TTL.
Point five is worth spelling out: the key has to survive re-pinning the room to a server. The pin is also dropped when the pinned server is unavailable to one particular user because of per-user restrictions, which says nothing about whether the room is still occupied. Were the key to go with the pin, and the room then returned to the same server, the participants already connected would hold the old key while the next joiner got a new one — everybody sees everybody, and no media decrypts. The key has no TTL for the same reason: a day of uninterrupted conversation with nobody new joining would drop the key out from under a live room.
VOICE_SERVER_UPDATE fields
Section titled “VOICE_SERVER_UPDATE fields”| Field | Type | Meaning |
|---|---|---|
e2ee_key |
string | null |
Base64 room key (32 bytes). null when the instance has encryption disabled |
e2ee_required |
boolean |
When true, the client must only connect with encryption enabled |
Both fields are always present in the event: when the backend omits them, the gateway substitutes null and
false. Checking for null is enough — there is no need to handle a missing field.
With e2ee_required = true, a client that cannot encrypt (no insertable streams) does not connect and
surfaces an error instead. There is no silent fallback to an unencrypted session.
If a key arrives but cannot be used (no insertable streams, a key that will not decode, a worker that will
not construct) and e2ee_required = false, the client connects unencrypted — but marks the connection as
faulty and shows “Key error” rather than “Transport only”. Everyone else in the room is encrypting at that
point, so such a participant hears none of them, and presenting a healthy connection would be misleading.
An instance controls this with VOICE_E2EE_ENABLED and VOICE_E2EE_REQUIRED (both default to true).
Rolling this out on a live instance
Section titled “Rolling this out on a live instance”A client that knows nothing about e2ee_key joins the room unencrypted: stale tabs on a cached bundle,
desktop builds that have not updated, third-party bots. Sharing a room with encrypting participants, such a
client hears none of them and none of them hear it, with no diagnostics on its side because it has no idea
encryption is involved. There is no version gate in the scheme.
Only VOICE_E2EE_ENABLED=false prevents mixed rooms: no key is issued and nobody encrypts. So:
- Ship the backend and the client with
VOICE_E2EE_ENABLED=false. - Wait for clients to turn over.
- Switch
VOICE_E2EE_ENABLED=true.
VOICE_E2EE_REQUIRED does not serve this purpose and does not behave the way one might expect. As long as a
key is issued, every capable client encrypts regardless of required — the flag governs not encryption but
what happens to a client that cannot encrypt. Under VOICE_E2EE_REQUIRED=false such a client joins the
room unencrypted: it will not hear the others and they will not hear it, without a single error message.
Hence the true default, which turns that into an explicit refusal to connect. Setting false only makes
sense where a degraded mode is genuinely wanted.
Known limitations
Section titled “Known limitations”- Not zero-trust. See the callout above: key distribution runs through our control plane.
- The key lives for the whole call and is not rotated. Group DM membership can change mid-call, and a removed participant keeps the key until the call ends. They can only use it while they still have access to the ciphertext, i.e. while they remain connected to the SFU room — the platform does not force-disconnect someone from an ongoing call when they are removed from a group, encryption or not. Rotating without a reconnect needs a dedicated gateway event to push the new key to the remaining participants and is not implemented in the current scheme.
- Metadata is not hidden. Who talks to whom, when, for how long, who is speaking right now, volume, bitrate, frame sizes — all of that remains visible to the server. What is encrypted is media content, not the fact of the call.
- Server-side features that need to read media stop working. Call recording and LiveKit egress only ever see ciphertext.
- Media only. Text messages, attachments and everything else are not covered by E2EE; they are stored and processed by the server in the clear.
- All of a room’s crypto runs in one worker. Each connection spawns a single insertable-streams worker, and that same worker decrypts every remote participant’s frames. Audio will not notice, but a channel with several cameras on will bottleneck on that thread.
RED(audio redundancy) is disabled automatically.livekit-clientturns RED off when E2EE is on, which can slightly reduce audio resilience to packet loss on a bad network.
Client requirements
Section titled “Client requirements”Insertable streams (Encoded Transform) support is required: current Chrome/Edge, Safari 15.4+, and the desktop
app. Firefox does not support them in every build — with e2ee_required = true the connection there is
rejected with a clear error.
