WebRTC behind a firewall: TURN over TLS on port 443
Calls that fail on corporate networks usually need TURN over TLS on 443. How to configure it, confirm it works, and why it should stay the last resort.
The call works from home, from the office guest Wi-Fi, from a phone on LTE. Then a customer tries it from inside their company network and ICE sits in checking until it gives up. No media, no error worth reading, and a support ticket that says “WebRTC doesn’t work here”.
Most of the time the network only lets traffic out on TCP 443 and drops everything else, UDP included. Every candidate your client gathered needs UDP, so none of them can reach anything. The fix is a TURN server the browser can reach over TLS on port 443, which to the firewall looks like one more HTTPS connection.
Why WebRTC fails on locked-down networks
ICE tries host candidates, then server-reflexive candidates from STUN, then relay candidates from TURN. The first two are UDP by definition. A default TURN setup on turn:host:3478 is UDP as well. On a network that blocks outbound UDP, the whole candidate list is dead before the first connectivity check.
Plain TCP to a TURN server does not always save you either. A firewall that inspects traffic on port 443 expects a TLS handshake. Raw TURN over TCP on 443 is not TLS, so a strict inspection box drops it. TLS on 443 passes because it is indistinguishable from the traffic the firewall exists to allow.
Configure all three transports
The browser gathers one relay candidate per TURN URL it can reach, so list the transports explicitly:
const pc = new RTCPeerConnection({
iceServers: [
{
urls: [
'turn:turn.example.com:3478?transport=udp',
'turn:turn.example.com:3478?transport=tcp',
'turns:turn.example.com:443?transport=tcp',
],
username,
credential,
},
],
});
The turns: scheme comes from RFC 7065 and means TLS between the browser and the TURN server. The ?transport=tcp is redundant on a turns: URL but harmless, and some older stacks expect it.
Three details catch people on the server side:
- The TLS certificate has to be valid for the hostname in the URL. The browser checks it, so a
turns:URL with a bare IP address or a self-signed certificate fails. - UDP 443 and TCP 443 are separate sockets. The same server can listen for TURN over UDP and TURN over TLS on port 443 at once, which helps on networks that allow UDP to 443 for QUIC.
- Each URL is a separate allocation on your TURN server, held and refreshed for the life of the call. Three URLs means up to three allocations per client. That matters more if you also pre-gather with iceCandidatePoolSize.
The relay leg is still UDP
TLS only covers the hop between the browser and your TURN server. The allocation itself relays UDP. RFC 8656 allocations carry UDP to the peer, and the TCP allocations of RFC 6062 are not something browsers request. So the path is TLS from the client to the relay, then UDP from the relay to your SFU or the other peer. Put the TURN server close to the media server and that second leg is cheap.
The TLS layer also adds no secrecy. The media inside is already DTLS-SRTP. TLS here exists to get past the firewall, and you pay for a second layer of encryption and a TLS handshake before ICE can start checking.
Confirm which path the call took
getStats() tells you which candidate won and how it reached the relay:
const stats = await pc.getStats();
for (const s of stats.values()) {
if (s.type !== 'transport' || !s.selectedCandidatePairId) continue;
const pair = stats.get(s.selectedCandidatePairId);
const local = stats.get(pair.localCandidateId);
console.log(local.candidateType, local.relayProtocol, local.url);
}
candidateType is relay when TURN carried the call. relayProtocol is udp, tcp or tls, meaning the transport between the browser and the TURN server. url names the ICE server entry that produced the candidate, which settles which of your three URLs did the work.
To test the fallback on purpose, set iceTransportPolicy: 'relay' so only relay candidates are used. Then block outbound UDP on the test machine. If the call still connects and relayProtocol reads tls, the path works.
Read icecandidateerror, with care
When gathering fails for a particular server, the peer connection fires icecandidateerror:
pc.addEventListener('icecandidateerror', (e) => {
console.log(e.url, e.errorCode, e.errorText);
});
errorCode is either a STUN error code from the server, such as 401 for bad credentials, or 701, which the spec reserves for a server that could not be reached at all. On a network that blocks UDP, expect 701 for every UDP and plain TCP URL. That is the firewall doing what it does, not a fault. The event only matters when the turns: URL fails too, and then the errorCode tells you whether you are looking at credentials, the certificate or reachability.
If the only way out of the network is an explicit HTTP proxy, whether the browser tunnels TURN through it depends on the engine and the proxy setup. Test on the customer’s actual network before promising anything.
Why TLS should stay the last resort
Media over TCP is worse media. A lost segment stalls every packet behind it until TCP retransmits, so a single loss turns into a burst of late packets. The browser’s congestion controller reads that as a delay spike and cuts the bitrate. NACK is pointless because TCP already retransmitted. The video freezes you get from this look like jitter in the counters, because that is what TCP turns loss into.
ICE already knows this. libwebrtc gives relay candidates a lower priority over TCP than over UDP, and lower again over TLS, so TLS only wins when nothing else connects. Keep it that way. Do not strip the UDP URLs to “simplify” the config, and do not put a TLS-only TURN server first in the list on the assumption that order matters more than priority.
What to monitor
Log relayProtocol for every call. The share of calls on tls shows how many of your users sit behind restrictive networks, and it is the number to watch when you move TURN providers or change ports. If it jumps after a change, some network that used to take UDP is now forcing the fallback, and those users are getting worse calls without an error anywhere.