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
Head-of-line blocking on a TCP relay path Two timelines of eight media packets. On the UDP relay the packet lost at 120 milliseconds simply leaves a gap and later packets keep arriving on schedule. On the TLS relay every packet after the loss is held in the TCP receive queue for about 250 milliseconds and then flushed as one burst. Delivery of eight media packets with one loss at t = 120 ms UDP relay turn:3478 loss NACK retransmit gap concealed, playout never pauses TLS relay turns:443 head-of-line stall, one 250 ms RTO burst of five frames 0 100 200 300 400 500 600 700 800 ms since first packet delivered to jitter buffer lost on the wire held in TCP receive queue UDP hides a single loss; TCP holds every later packet until the retransmit lands.
One lost packet costs nothing on a UDP relay and roughly 250 ms of frozen playout on a TLS relay, because TCP will not deliver out of order.

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));
}
Encapsulation of a media packet inside TURN over TLS on 443 Nested layers from the outside in: an IPv4 plus TCP header addressed to port 443, a TLS 1.3 record carrying the SNI of the relay hostname, RFC 6544 stream framing, a TURN ChannelData header, an SRTP packet, and finally the VP8 payload. A side panel totals the bytes each layer adds. One 1100-byte video packet on the turns:443 path IPv4 + TCP, destination port 443 TLS 1.3 record, SNI turn.example.com RFC 6544 stream framing, 4-byte aligned TURN ChannelData, channel 0x4001 SRTP header + authentication tag VP8 payload opaque to the relay and the proxy Bytes added per packet IPv4 + TCP header 40 TLS 1.3 record 29 framing padding 0-3 ChannelData header 4 SRTP header + tag 22 overhead on 1100 B 8.6% same packet over UDP 3.1% Only the client-to-relay leg is TCP. The relay forwards to the far peer over UDP, so only half the path pays the ordering penalty. To a DPI middlebox this is a TLS 1.3 session to port 443 with a public-CA certificate.
Encapsulation on the 443 path: 95 bytes of framing wrap each media packet, and everything above the TCP header is indistinguishable from HTTPS.

Reproduction Steps & Debugging Log Patterns

  1. 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 drop plus a tcp dport != 443 drop companion rule is enough, and reverting is one nft flush chain.
  2. Run probeTlsRelay() from the implementation above. With iceTransportPolicy: '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.
  3. Confirm from outside the browser that the listener is reachable and looks right: openssl s_client -connect turn.example.com:443 -servername turn.example.com must print a full chain and Verify 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.
  4. Start the real call and read the nominated pair once connectionState reaches connected, polling getStats() at 1 s intervals.
  5. Compare the resulting jitterBufferDelay and framesPerSecond against 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.

Decision tree for a failing relay-only call on 443 Starting from a relay-only peer connection carrying only the turns 443 server, the tree branches on whether a relay candidate appears within five seconds, then on whether the TLS handshake completes, and finally on whether the nominated pair reports a relayProtocol of tls or udp. Isolating the failure when relay-only calls do not connect iceTransportPolicy: 'relay' with only the turns:443 entry No relay candidate in 5 s does TLS to :443 complete? Relay candidate appears read pair relayProtocol nothing gathered gathered TLS never completes port or SNI filtered; check proxy CONNECT TLS ok, 401 Allocate credential expired or realm string mismatch relayProtocol udp another server won; drop it and retest relayProtocol tls path proven; freezes now mean TCP stalls fails ok udp tls Every branch is observable from getStats(); none of it needs access to the firewall.
Four outcomes, each distinguished by one observable: candidate gathering, the TLS handshake, the Allocate response, and the nominated pair's relayProtocol.

Common Implementation Mistakes

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.