Skip to main content
Hat Boy Software
2 days
from first commit to calls switched on in production
1
upload per participant, whatever the room size (was one per peer)
0
changes to the page's Content Security Policy
19 / 19
synthetic three-browser checks passing over UDP, over TCP/443, and unpinned

Client: an early-stage intelligence-services platform whose users communicate through a confidential channel. The application runs on a hardened, private Kubernetes cluster behind a strict Content Security Policy, and confidentiality is the product’s premise.

The challenge

The pilot needed participants to talk live. Until then, a “call” was a link to an outside meeting service, so the most sensitive part of the work happened somewhere else.

Embedding a commercial meeting SDK was the obvious route, and it was the wrong one. The page’s policy allows network connections to its own origin and nothing else (connect-src 'self'). That one line is what makes “this page can only talk to its own backend” a fact rather than a promise. A meeting SDK would have needed outside connection hosts, a script host, WebAssembly, and, for HD video, cross-origin isolation. Cross-origin isolation would have blanked the embedded e-signature ceremony and the policy documents on the same page.

There were three more constraints:

  • Identity. No participant may learn another’s email address, name, or record ID through the call.
  • Who may join. Only the people already authorized for that conversation, decided on the server.
  • No new exposure. The cluster’s nodes have no public IP addresses, and its pod-security policy forbids host networking. Both rules stay.

What we did

Timeline of the two days. Day 1 morning: a signalling server and a forced TURN relay, a peer-to-peer mesh with no CSP change. Afternoon: the call page, the first real three-way test, and a handshake fix it found. Evening: audio measured, showing a saturated uplink; video capped and audio prioritized. Late: the SFU decided and built, LiveKit behind STUNner with signalling proxied on the application’s own origin. Day 2 morning: sized from measurement and switched on in production. Midday: relay credentials renewed mid-call and a microphone level meter. Afternoon: a five-minute probe, a TCP/443 fallback, a synthetic three-browser call and a dashboard.

Day one: a peer-to-peer mesh that changed no security policy

WebRTC media sits outside connect-src: RTCPeerConnection carries DTLS-SRTP, which that directive does not govern. So we built the call in-house and left the security headers untouched.

  • One server endpoint decides everything. It handles join, signalling, polling, and leave. It works out who may join from records that already exist, so the data model did not change.
  • Media is forced through a TURN relay. Participants never exchange their own network addresses. Each one appears to the others under a fresh, random identity per join.
  • Signalling fans out over the message bus, because the backend runs two replicas. With in-memory signalling alone, half of every handshake would go missing.

Measuring before fixing

The first real three-way call worked, but one participant’s audio was choppy, and only for the two people listening. Earlier in the day, guessing had cost us time: three plausible causes had each been true of the layer below and none of them was the bug. So we built a measurement into the call page instead of guessing again.

The page reduces the browser’s getStats() to three numbers, each a delta rather than a lifetime total. The key one is concealment: the share of audio the browser had to invent to cover gaps. It is not a proxy for choppiness; it is choppiness, counted.

The numbers were decisive. Both listeners saw 8.5% concealment, 35–42 ms of jitter, and 0.0% packet loss on that participant’s audio, while the participant’s own incoming audio was clean. Jitter with zero loss means packets were being queued, not dropped: a saturated uplink.

How the choppy audio was diagnosed. The speaker’s uplink was sending two copies of their media, one per listener, and packets waited in a queue. Listener 1 measured 8.5% concealed audio, 35 ms jitter and 0.0% loss on the speaker; listener 2 measured 8.5% concealed, 42 ms jitter and 0.0% loss. The speaker’s own incoming audio was clean. No loss rules out the network path, clean incoming audio rules out the device, and zero loss with high jitter means queued packets: a saturated uplink. Capping video at 350 kbps and prioritizing audio eased it; an SFU, with one upload per person, cures it.

That is the structural cost of a mesh. Each participant uploads one copy of their media per peer, and each copy runs its own congestion controller, so the copies compete for the same link without knowing about each other. We capped video at 350 kbps per peer and gave audio priority. That eased the symptom, but the cost still grew with the size of the room.

Mesh compared with SFU. In a three-person mesh, each person uploads two copies of their media, six uploads in all, and each copy runs its own congestion control, so they compete for one uplink. With an SFU, each person uploads one copy, three in all, and the SFU forwards it to the others. Uploads per person: with 3 people, mesh 2 and SFU 1; with 4, mesh 3 and SFU 1; with 6, mesh 5 and SFU 1.

Day two: a self-hosted SFU, without weakening the cluster

We wrote up the decision record and moved the call to a selective forwarding unit (SFU). With an SFU, each participant uploads one copy, whatever the room size. That also makes simulcast and server-side recording possible.

Self-hosted, not managed. Two managed SFU services were cheaper and easier to run. We rejected them for one stated reason. A TURN relay forwards encrypted packets it cannot read; any SFU decrypts and re-encrypts. For a product built on a confidential channel, that ability should sit inside the client’s own cluster, not with a new third party. The decision record says plainly how narrow this argument is: the edge provider already terminates TLS for the rest of the application. The aim was not to extend its reach to the one kind of data it could not yet see.

LiveKit behind STUNner, not LiveKit on host networking. We checked LiveKit’s standard deployment against the live cluster and found two hard blockers. The nodes have no external IP addresses, and the pod-security policy refuses host networking. STUNner runs a media server in ordinary pods on a private cluster, so we put LiveKit behind it rather than open a privileged namespace and a 10,000-port UDP range.

The security policy still did not move. We proxied the SFU’s signalling WebSocket through the application’s own origin, the same way the sign-in endpoints already were. connect-src 'self' held exactly as written, which was the main bet in the decision record.

Call architecture. The participant’s browser runs under a Content Security Policy of connect-src ‘self’, and each participant gets a random identity per join, so no email, name or record ID crosses the call. Signalling goes to the application’s own origin, which proxies the SFU’s signalling socket and passes join requests to the backend; the backend decides who may join and mints short-lived credentials. Encrypted media goes to a TURN relay, STUNner, running in ordinary pods with one public media address, over UDP with a TCP/443 fallback, and on to the LiveKit SFU, which has an internal address only and is the only place media is decrypted. Everything server-side runs in a private Kubernetes cluster with no public node IPs and host networking forbidden, unchanged by the work. No third party can read call media.

Making it observable, then switching it on

  • A probe every five minutes. It performs a real TURN allocation against each relay address, using the same credentials the backend issues, and asks the SFU to list its rooms. If the backend’s secret and the relay’s secret ever differ, the probe reports it before a participant does.
  • A TCP/443 fallback for networks that block UDP, which are common on institutional networks.
  • A synthetic three-browser call runs on a schedule. Three real browsers sign in as dedicated test accounts, pass every gate a person would, and hold a call through the relay and the SFU. Every assertion reads getStats(): audio arriving, a relayed path, video present only while cameras are open. Each transport is pinned and proven separately, because a browser falls back silently, and a pass with nothing pinned proves only one path. Against the test environment, the result was 19 of 19 checks passing over UDP, over TCP/443, and unpinned.
  • Mid-call credential renewal, so media survives past the first hour. The relay checks expiry on every refresh, not only when a call first connects.
  • Production behind a switch. Production uses a per-environment switch that turns on only together with the SFU and the relay. A test fails the build if one is enabled without the others.

How the call is checked continuously. Every five minutes a probe asks the relay for an allocation, expects the 401 challenge, allocates again with the backend’s own credentials and expects success, releases the allocation, and asks the SFU to list its rooms. Any failure raises an alert, so a secret that differs between backend and relay fails there rather than on a call. On a schedule, a synthetic call has three real browsers sign in as test accounts, pass every gate a person would, and hold one call through the relay and the SFU: 19 of 19 checks pass pinned to UDP, pinned to TCP/443, and unpinned. Transports are pinned because a browser falls back silently. Every assertion reads getStats(): audio bytes arrive, a relayed path is in use, no video before cameras open, video once they open, none after they close. There is also a dashboard for call availability and the media plane.

Results

OutcomeResult
CallingBuilt from nothing to three-way audio and video in production in two days
UplinkOne upload per participant, whatever the room size, instead of one per peer
Security policyNo change to the Content Security Policy, the pod-security policy, or node exposure
Media confidentialityNo third party in the media path; the SFU runs inside the client’s own cluster
Participant privacyA random identity per join; no email, name, or record ID crosses the call
Restrictive networksTURN over UDP, with a TCP/443 fallback, each proven separately in the test environment
DetectionFive-minute relay and SFU probe, plus a scheduled synthetic three-browser call
Engineering disciplineFixes shipped with tests, and key protections were mutation-checked, so a test that could not fail was caught

The honest footnote

Four failures in the rollout all looked healthy until we checked a specific link. A TCP listener was accepted and served nothing. A resource quota, not capacity, blocked the deployment. The half of the backend that issues call tokens was missing its keys while the SFU reported Ready. And with one setting left at its default, the backend issued credentials for the wrong relay: every credential was valid, and no call could connect. None of these turned anything red.

The first real test also found that no handshake had ever crossed. The browser code and the server each had passing tests, but nothing tested the contract between them. We now test that seam with a transport that enforces the server’s rules, not one that just records calls. The broader lesson is that for real-time media, “healthy” is not evidence. That is why the probe and the synthetic call exist.

The client's identity is anonymized. The environment, findings, and results are drawn from a real Hat Boy Software engagement.

More case studies

Not sure where to start? Start with an assessment.

A senior review of your app, cloud estate, or AI platform. Scoped and quoted before work starts. It ends in a prioritized plan, so you decide what to fix and when.

Talk to an engineer