End-to-end encryption
MOQOM encrypts media payloads with SFrame (RFC 9605) carried over MOQT. Only the payload is encrypted, so relays can still cache, prioritise and fan out objects — they just cannot read them. Tampering with an object’s group, object number, priority or properties causes decryption to fail.
Not even MOQOM can see the media. Today your app distributes the room key, and MOQOM’s relays, control plane and cloud never receive it. Encryption adds no perceptible latency: about 54 µs per 1080p keyframe against a 33 ms frame. Its real cost is bytes, 19 per object, which is 0.9% of a 2 KB video object but a larger share of a small audio packet.
| Default cipher suite | AES-256-GCM (SFrame suite 0x0005) |
| Also supported | AES-128-GCM |
| Overhead | 19 bytes per object |
| Transport encryption | TLS 1.3 on every QUIC connection, always — independent of SFrame |
On-device SFrame cost measured at roughly 7.5 µs per Opus packet and 54 µs per 1080p keyframe, against a 33 ms frame budget.
Required by default
Section titled “Required by default”Every room requires end-to-end encryption unless its backend has turned it off. The room’s policy
travels to each device in the e2ee claim of the signed client token: "required" or
"optional". A token without the claim is treated as "required", so an older control plane or a
hand-minted token can only make the SDK stricter, never looser.
In a room that requires encryption, MoqomKit:
- refuses to publish without a key.
startBroadcastandstartAudiothrowMoqomError.encryptionRequiredbefore anything is announced to the relay; - drops unencrypted incoming media. With no key set, media from other participants is
discarded and
room.eventsyields.encryptionRequired(participant)once per participant. With a key set, an object that does not decrypt is discarded and counted inroom.decryptionStatistics; - stops a clear publication if the policy tightens mid-call. If a renewed token says
"required"and no key is set, the SDK ends the broadcast and audio and yields.encryptionRequiredfor the local participant.
The policy covers audio and video. Room metadata, such as the track catalog and the room’s control log, is not media and still travels under TLS only.
Turning it on (Swift)
Section titled “Turning it on (Swift)”Set encryption on the room before going live. Every participant needs the same key material.
let room = try await session.join(room: RoomID("demo"))
let encryption = RoomEncryption.sharedKey(baseKey, keyID: 1) // AES-256-GCM by defaultawait room.useEncryption(encryption)
try await room.startBroadcast(from: camera).start()room.encryptionPolicy is the policy the token carried, and room.isEncrypted says whether a
key is set. Without the useEncryption call, the startBroadcast above throws
MoqomError.encryptionRequired.
RoomEncryption.sharedKey is the simplest scheme that is genuinely end to end: every member holds
one base key. Its limits are real — any member can impersonate any other, and removing a member
means rekeying everyone — so it suits small, trusted calls. Distribute the key over a channel
MOQOM does not see (for example, your own end-to-end encrypted messaging).
room.decryptionStatistics reports objects that failed to decrypt, which is the first thing to
check when one participant sees black tiles.
Turning it off for a room (backend)
Section titled “Turning it off for a room (backend)”Some rooms need the server to see media: server-side recording, cloud classifiers, or a
broadcast that has to reach clients without key distribution. Only a backend can turn encryption
off, one room at a time, and it has to say why. Set the room’s e2ee policy to optional with
UpdateRoomPolicy, name e2ee in the update mask, and give actor.reason; the control plane
refuses the change without a reason.
r := client.Room("live/42")_, err := r.UpdatePolicy(ctx, moqom.PolicyUpdate{ Policy: moqom.Policy{E2EE: moqom.E2EEOptional}, Fields: []string{"e2ee"}, Actor: moqom.Actor{Reason: "server-side recording for the weekly broadcast"},})use moqom_sdk::{Actor, E2eePolicy, Policy, PolicyUpdate};
client.room("live/42").update_policy(PolicyUpdate { policy: Policy { e2ee: E2eePolicy::Optional, ..Default::default() }, fields: vec!["e2ee".into()], actor: Actor { reason: "server-side recording for the weekly broadcast".into(), ..Default::default() },}).await?;The same rule applies to CreateRoom: creating a room with e2ee optional needs a reason.
Either way, the change is written to the room’s moderation log with a policy_change such as
e2ee: required -> optional and announced on the room’s event stream as a moderation event.
Tokens minted after the change carry "e2ee": "optional", and devices pick it up at their next
token renewal. In an optional room, a participant without a key publishes and receives in the
clear. A participant who has called useEncryption still encrypts what it sends and still
discards media it cannot decrypt, so mixing keyed and unkeyed participants in one room leaves
each unable to see the other. Setting the policy back to required needs no reason, and devices
that have no key stop publishing at their next renewal.
An UpdateRoomPolicy call with no update mask applies every field, so a request that leaves
e2ee unset puts the room back to required.
What changes in an encrypted room
Section titled “What changes in an encrypted room”| Unencrypted | End-to-end encrypted | |
|---|---|---|
| Relay sees | Payloads | Ciphertext only |
| Server-side recording / classifiers | Possible | Not possible — watchdogs see ciphertext |
| On-device taps (captions, classification) | Yes | Yes — taps receive plaintext on the device that holds the key |
| Moderation (kick, ban, force-mute) | Yes | Yes — moderation acts on sessions, not content |
Key management roadmap
Section titled “Key management roadmap”Group key management for E2EE rooms is designed around MLS (RFC 9420)
for small rooms and sender keys for large ones, with SFrame underneath both. This is not yet
built; until it ships, use sharedKey with your own key distribution.