Skip to content

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.

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.

  1. The backend generates 32 random bytes (crypto.randomBytes) per room and stores them in Redis under voice:room:e2ee:guild:{guild_id}:{channel_id} or voice:room:e2ee:dm:{channel_id}. Creation is atomic (SET … NX GET), so every participant of a room is guaranteed the same key.
  2. 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.
  3. The gateway relays the key in VOICE_SERVER_UPDATE (e2ee_key and e2ee_required) and never interprets it.
  4. The client decodes the base64 into raw bytes, hands them to ExternalE2EEKeyProvider from livekit-client (raw bytes mean HKDF; a string would mean PBKDF2 over a passphrase), enables encryption, and only then connects to the room.
  5. The key lives as long as the room itself and is dropped on the room_finished webhook 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.

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).

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:

  1. Ship the backend and the client with VOICE_E2EE_ENABLED=false.
  2. Wait for clients to turn over.
  3. 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.

  • 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-client turns RED off when E2EE is on, which can slightly reduce audio resilience to packet loss on a bad network.

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.