Forcing TURN over TCP 443 on Locked-Down Networks
Corporate, hospital, and hotel networks that permit exactly one outbound destination port β 443/tcp β will fail every WebRTC call that needs UDP, and they fail it quietly: ICE gathers host and server-reflexive candidates, connectivity checks time out, and the session lands in failed with no error a user can act on. This guide is part of the TURN Server Configuration & Auth section of the WebRTC Protocol Stack & Signaling Servers guide. It settles one decision: whether to publish turns: on the registered port 5349 or on 443, how to make the TLS-wrapped relay indistinguishable from ordinary HTTPS to a middlebox, and what that path costs in throughput and latency once it is working.
Context & Trade-offs
Restrictive networks come in three escalating grades, and each one eliminates a transport. Grade one blocks UDP outright but allows arbitrary outbound TCP β turn:relay.example.com:3478?transport=tcp is enough. Grade two adds an egress allowlist keyed on destination port, typically 80 and 443 only; here 3478 and 5349 both vanish, and the only survivor is a relay listening on 443. Grade three adds deep packet inspection or a forced HTTP proxy, so the bytes on 443 must actually look like a TLS session to a web host: a real ClientHello, an SNI value that resolves publicly, and a certificate chaining to a public CA.
That last grade is why turns: on 5349 is not a substitute for turns: on 443. Port 5349 is IANA-registered for TURN over TLS, which is precisely the problem β it is a known, enumerable, easily blocked port, and no firewall vendorβs default allowlist template includes it. Nothing about the traffic changes when you move the same TLS listener to 443; what changes is that the port number stops identifying the traffic as real-time media. A middlebox that filters on port alone waves it through, and a middlebox that inspects the handshake sees a normal TLS 1.3 negotiation to a hostname with a valid certificate.
The cost is not free. A relay hop already adds 20β40 ms one-way versus a direct path, and layering TCP underneath it adds two failure modes that UDP relay does not have. The first is head-of-line blocking: a single lost segment holds every subsequent packet in the receiverβs TCP queue until the retransmit arrives, which at a 250 ms retransmission timeout converts a 1% loss event into a quarter-second freeze followed by a burst that the jitter buffer must absorb. The second is stacked congestion control β TCPβs own window reduction fights WebRTCβs bandwidth estimator, so the estimator sees delay spikes it cannot attribute and backs off further than the link requires. In practice, cap video on this path at 1.0β1.5 Mbps and expect jitter buffer delay to settle around 300β500 ms rather than the 40β80 ms a UDP relay sustains.
| Transport | Survives port allowlist | Looks like HTTPS | Added one-way latency | Behaviour at 2% loss |
|---|---|---|---|---|
turn:3478?transport=udp |
no | no | 20β40 ms | loss concealed, no stall |
turn:3478?transport=tcp |
no | no | 30β55 ms | 200β300 ms stalls |
turns:5349?transport=tcp |
rarely | yes, wrong port | 35β60 ms | 200β300 ms stalls |
turns:443?transport=tcp |
yes | yes | 35β60 ms | 200β300 ms stalls |
None of this means you should serve every call over 443. List all four transports in iceServers and let ICE priority do the selection: host beats server-reflexive, server-reflexive beats relay, and among relay candidates the UDP one carries a higher priority than the TCP one, so the 443 path is only nominated when everything cheaper has failed its connectivity checks. That ordering is the same mechanism described in Traversing Symmetric NAT with TURN, and it means adding the 443 entry costs a few extra Allocate requests, not extra latency on healthy networks.
One behaviour catches teams out on managed desktops: when the operating system or a PAC file declares an HTTP proxy, Chrome and Edge route TCP-based TURN through it with an HTTP CONNECT turn.example.com:443 request instead of dialling the relay directly. That is usually good news, since the proxy is exactly the box the network administrator has already blessed, but it introduces two new failure points. A proxy that demands authentication will answer 407 and the browser has no credential to offer from within the media stack, so the allocation dies before any TLS bytes are sent. A proxy that only permits CONNECT to a fixed hostname list will answer 403 for anything unlisted. Neither shows up as a WebRTC error β you see an absent relay candidate and nothing else β which is why the reproduction below checks the raw socket separately from the browser.
Minimal Runnable Implementation
On the server, the whole change is a TLS listener on 443 with a publicly trusted certificate, plus the removal of anything that refuses TCP relay allocations. Two coturn defaults will bite you here: alt-tls-listening-port defaults to one above the TLS port, so a listener on 443 silently tries to grab 444 as well, and no-tcp-relay β recommended for consumer-only relays in Configuring Coturn for Production TURN Relay β must be absent.
# /etc/turnserver.conf β additions for a 443 listener
listening-port=3478 # keep plain STUN/TURN for healthy networks
tls-listening-port=443 # the only port a grade-two firewall lets out
alt-listening-port=0 # disable the implicit 3479 listener
alt-tls-listening-port=0 # disable the implicit 444 listener, or bind fails
cert=/etc/letsencrypt/live/turn.example.com/fullchain.pem # public CA chain, not self-signed
pkey=/etc/letsencrypt/live/turn.example.com/privkey.pem
no-tlsv1 # a TLS 1.0 hello is itself a DPI red flag
no-tlsv1_1
cipher-list="ECDHE+AESGCM:ECDHE+CHACHA20" # modern suites only; matches what browsers offer
# no-tcp-relay MUST NOT appear here β it rejects every allocation from this listener
total-quota=1200 # TCP allocations hold a socket each; budget file descriptors
max-bps=1500000 # 1.5 Mbps ceiling: TCP relay cannot carry more without stalling
Binding 443 as the unprivileged turnserver user needs setcap cap_net_bind_service=+ep /usr/bin/turnserver, and 443 must belong to this daemon alone β if a web server already owns it, put TURN on a second IP address or front both with an SNI-routing proxy that forwards turn.example.com to the relay and everything else to the web backend. On the client, the URL list is ordered cheapest-first and the credentials are the short-lived ones described in Time-Limited TURN Credentials with HMAC.
// Credentials are minted per session by the backend; TTL 300-600 s is typical.
const { username, credential } = await fetch('/api/turn-credentials').then((r) => r.json());
const iceServers = [
{ urls: 'stun:stun.example.com:3478' },
// Fast path. Nominated whenever the network lets UDP out at all.
{ urls: 'turn:turn.example.com:3478?transport=udp', username, credential },
// UDP blocked, arbitrary TCP allowed.
{ urls: 'turn:turn.example.com:3478?transport=tcp', username, credential },
// Port-allowlisted or DPI network. turns: implies TCP, but state it so no stack guesses.
{ urls: 'turns:turn.example.com:443?transport=tcp', username, credential }
];
const pc = new RTCPeerConnection({
iceServers,
iceCandidatePoolSize: 1 // pre-warm one allocation; TLS adds a handshake RTT
});
// Test harness only: prove the 443 path in isolation by discarding every other candidate.
function probeTlsRelay() {
const probe = new RTCPeerConnection({
iceServers: [iceServers[3]], // ONLY the turns:443 entry
iceTransportPolicy: 'relay' // drop host and srflx candidates entirely
});
probe.createDataChannel('probe'); // a media section is required to start gathering
const deadline = setTimeout(() => {
console.error('no relay candidate in 5000 ms β 443 path is blocked upstream');
probe.close();
}, 5000); // 3-5 s is the usual fallback window
probe.onicecandidate = ({ candidate }) => {
if (!candidate) return; // null marks end-of-gathering, not a candidate
clearTimeout(deadline);
// Chrome exposes .url and .relayProtocol; both must name the 443 listener.
console.log(candidate.type, candidate.url, candidate.relayProtocol);
probe.close();
};
probe.createOffer().then((o) => probe.setLocalDescription(o));
}
Reproduction Steps & Debugging Log Patterns
- Recreate the restriction rather than waiting to hit it in the field. On the client host, drop all outbound UDP and all outbound TCP except 443 β
sudo nft add rule inet filter output udp dport != 0 dropplus atcp dport != 443 dropcompanion rule is enough, and reverting is onenft flush chain. - Run
probeTlsRelay()from the implementation above. WithiceTransportPolicy: 'relay'and a single server entry, the only candidate that can appear is a relay candidate allocated through 443, so a five-second silence is unambiguous evidence the path is blocked rather than merely unpreferred. - Confirm from outside the browser that the listener is reachable and looks right:
openssl s_client -connect turn.example.com:443 -servername turn.example.commust print a full chain andVerify return code: 0. If the returned certificate is issued by your employerβs CA, a TLS-intercepting proxy is terminating the session, and TURN will not survive it. - Start the real call and read the nominated pair once
connectionStatereachesconnected, pollinggetStats()at 1 s intervals. - Compare the resulting
jitterBufferDelayandframesPerSecondagainst a UDP-relay run of the same call so you know how much of any freeze belongs to the transport rather than the encoder.
// Step 4: identify which transport actually carried the call.
const stats = await pc.getStats();
let pair;
stats.forEach((s) => {
if (s.type === 'candidate-pair' && s.nominated && s.state === 'succeeded') pair = s;
});
const local = stats.get(pair.localCandidateId);
console.log(local.candidateType, local.protocol, local.relayProtocol, local.url);
// Healthy 443 path β note protocol 'udp' is the relay-to-peer leg, relayProtocol 'tls'
// is the client-to-relay leg. Both fields must be read; either alone is misleading.
// relay udp tls turns:turn.example.com:443?transport=tcp
//
// Path never came up β no relay candidate was ever gathered:
// ICE connection state: checking -> disconnected -> failed
// getStats(): every candidate-pair state 'in-progress' then 'failed'
//
// Allocate rejected β TLS fine, credentials wrong:
// coturn: 401 realm=turn.example.com nonce=... session closed
Read both protocol fields together. local.protocol describes the relayβs socket toward the far peer and will normally say udp even on a healthy 443 path, so a team that checks only that field concludes the TLS listener was never used and starts debugging the wrong half of the connection. local.relayProtocol is the field that names the client-to-relay transport, and local.url confirms which of your configured servers issued the allocation β indispensable once you run more than one relay hostname. Firefox reports the same information through about:webrtc rather than these exact stat names, so keep the reproduction on Chrome when you are comparing runs.
Common Implementation Mistakes
- Publishing only
turns:on 5349. The TLS wrapping is correct and the port still fails a destination-port allowlist. If you can run only one TLS listener, run it on 443. - Sharing 443 with the web server without SNI routing. Two daemons cannot bind the same port on the same address; putting TURN behind an HTTP reverse proxy is worse, because the proxy expects an HTTP request after the handshake and closes the connection when it gets a STUN Allocate. Use a stream-mode proxy that routes on SNI, or a dedicated IP.
- Leaving
no-tcp-relayinturnserver.conf. The TLS handshake succeeds, the credential validates, and the Allocate is refused β the failure looks like an auth problem and is not one. - Assuming
turns:443makes the whole path TCP. Only the client-to-relay leg is TCP; the relay still forwards to the far peer over UDP from its 49152β65535 range. If the far peer is also UDP-blocked it needs its own TLS relay allocation, and the call then pays the ordering penalty twice. - Shipping
iceTransportPolicy: 'relay'. It is a diagnostic, not a configuration. Left in production it forces every call β including two laptops on the same LAN β through the relay, adding 20β40 ms one-way and multiplying your egress bill.
FAQ
Does moving TURN to 443 actually defeat deep packet inspection?
It defeats port-based filtering completely and protocol-classifying DPI most of the time, because what crosses the wire is a genuine TLS session with a valid SNI and a public certificate. It does not defeat a TLS-intercepting proxy: that box terminates the handshake itself, then expects HTTP, and TURN dies at the first STUN message. It also does not defeat SNI allowlisting, where only approved hostnames are permitted. In both cases the fix is administrative β get turn.example.com allowlisted β not technical.
Should I just serve every call over 443 and stop maintaining the other listeners?
No. ICE already prefers cheaper transports and only falls back to the TLS relay when connectivity checks on everything else fail, so keeping 3478 costs nothing on restricted networks and saves 8.6% of wire overhead plus every head-of-line stall on unrestricted ones. Relay-only traffic is also the most expensive bandwidth you buy.
How do I tell a TCP head-of-line stall apart from an encoder problem?
Watch jitterBufferDelay divided by jitterBufferEmittedCount alongside framesDecoded at 1 s intervals. A transport stall shows a flat decode count with a jitter buffer that grows past 300 ms and then drains in one step; an encoder problem shows falling framesEncoded on the sender with a steady buffer. The measurement patterns are covered in Interpreting getStats() for Congestion Signals, and the per-pair timing view in Reading chrome://webrtc-internals Dumps.
Related: return to TURN Server Configuration & Auth, harden the listener itself in Configuring Coturn for Production TURN Relay, and mint the short-lived credentials this path needs with Time-Limited TURN Credentials with HMAC.