Traversing Symmetric NAT with TURN Relays
Symmetric NAT is the single most common reason a peer-to-peer WebRTC connection refuses to form despite perfect signalling. This guide is part of the ICE Candidate Gathering & Filtering guide, and it answers one decision: when the network defeats direct paths, how do you force a working relay path and how much success can you actually expect from it.
Context & Trade-offs
A STUN binding works because most NATs reuse the same external IP:port for a given internal socket regardless of destination — a so-called cone NAT. The srflx candidate that STUN discovers is therefore valid for any remote peer. Symmetric NAT breaks this assumption: it allocates a new external port for every distinct destination IP:port. The mapping your peer discovered by talking to the STUN Server Deployment Strategies endpoint is useless to the remote peer, because when packets later flow to that peer the NAT assigns a different port. The advertised srflx candidate points at a hole that does not exist for the actual conversation, and every connectivity check against it fails.
There is no client-side trick that beats this. Two peers both behind symmetric NAT cannot establish a direct path, because neither can predict the port the other’s NAT will open. The only reliable answer is a relay: a TURN Server Configuration & Auth endpoint with a fixed, publicly reachable address that both peers send to. Each peer maintains a stable mapping to the TURN server, and the server forwards packets between them.
The numbers justify the cost. Across the open internet, roughly 8–20% of connections require a relay; for users behind symmetric NAT or carrier-grade NAT that figure climbs sharply, and pairs where both sides are symmetric approach 0% direct success. Provisioning TURN typically lifts overall connection success from the mid-80s into the high-90s percent range. The trade-off is latency and cost: relayed media adds roughly 20–40 ms of one-way latency depending on relay placement, and every byte traverses your infrastructure, so multi-region TURN placement (which cuts connect latency 40–60%) matters.
It helps to be precise about why the direct path is impossible rather than merely slow. With a cone NAT, the external mapping is a function of the internal socket alone, so the srflx candidate one peer learns from STUN is the same address every other peer will reach. With symmetric NAT the mapping is a function of the internal socket and the destination, so the external port that STUN revealed (when talking to your STUN server) is different from the external port the NAT will use when packets later flow toward the remote peer. The advertised candidate is therefore a prediction that is wrong by construction — there is no port-prediction heuristic that reliably beats it, and the ones that exist (birthday-attack style port scanning) are slow, fragile, and blocked by most carrier NATs. A relay sidesteps the problem entirely: both peers keep a single stable mapping to a fixed public address, and the TURN server stitches the two flows together. This is also why iceTransportPolicy: 'relay' is the cleanest way to prove a symmetric-NAT fix — if it connects with host and srflx suppressed, the relay is genuinely carrying the call.
What a TURN allocation actually costs
A relay path is not merely a longer route; it is a piece of server-side state with a lifecycle you have to keep alive. The client’s Allocate request makes the TURN server reserve one relayed transport address — a socket drawn from the 49152–65535 port range — and pin it to the exact 5-tuple the request arrived on. That allocation carries a default lifetime of 600 s, and the browser refreshes it with a Refresh transaction at roughly half that interval. If the refreshes stop being answered the socket is released, and the call keeps reporting connected for several seconds while no bytes move, because ICE consent and TURN allocation state expire on different clocks. Consent freshness rides on top of that: STUN binding indications on the nominated pair, tightened toward the sub-30 s cadence you already need on mobile and carrier-grade NAT paths to stop the operator’s NAT from reaping the mapping underneath you.
Permissions are a second, shorter-lived layer. Before the relay will forward a packet inbound from a remote peer, the client must install a CreatePermission for that peer’s address, valid for 300 s and refreshed by Chrome well before it lapses. Permissions match on IP only, never on port — which is precisely why they survive a symmetric NAT re-mapping the peer’s source port on every fresh burst, and precisely why they shatter the moment the peer’s public IP changes.
The data path adds its own tax. A packet forwarded through a plain Send indication carries 36 bytes of STUN framing on top of every payload; a ChannelBind collapses that to a 4-byte ChannelData header. Chrome issues the ChannelBind within the first handful of packets, so steady state is cheap, but the gap is real while it lasts: on a 2 Mbps stream at roughly 200 packets per second, indication framing costs about 58 kbps per direction against about 6 kbps for channel data. Channel bindings themselves expire after 600 s and are refreshed on the same schedule as the allocation, so a relay that looks healthy for exactly ten minutes and then goes quiet is almost always a refresh path being dropped by a stateful middlebox rather than a media problem.
Minimal Runnable Implementation
// Force a relay path so you can verify TURN works in isolation,
// then relax to 'all' in production to keep direct paths when available.
const pc = new RTCPeerConnection({
iceServers: [
{
urls: [
'turn:turn.example.com:3478?transport=udp', // primary relay
'turn:turn.example.com:3478?transport=tcp', // TCP fallback when UDP blocked
'turns:turn.example.com:5349?transport=tcp' // TLS fallback (firewalls, DPI)
],
username: 'time-limited-user', // from your HMAC credential service
credential: 'base64-hmac-token'
}
],
iceTransportPolicy: 'relay', // DROP host + srflx; relay candidates only
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
});
// Confirm a relay candidate was actually allocated
pc.onicecandidate = (e) => {
if (e.candidate && e.candidate.type === 'relay') {
console.log('Relay allocated:', e.candidate.address, e.candidate.port);
}
};
pc.onicecandidateerror = (e) => {
// 401 here means TURN auth failed — check username/credential expiry
console.error(`ICE error [${e.errorCode}] ${e.errorText} on ${e.url}`);
};
In production set iceTransportPolicy: 'all' so cone-NAT users still get a fast direct path; 'relay' is the diagnostic mode that proves your TURN deployment in isolation. Always offer UDP, TCP, and TLS (5349) transports so users behind UDP-blocking firewalls and deep-packet-inspection middleboxes still reach the relay, and on the most restrictive corporate networks go one step further by Forcing TURN over TCP 443 on Locked-Down Networks.
Matching the relay configuration to the client
A large share of “TURN doesn’t work” reports are not client bugs at all — the client is asking for something the relay was never configured to hand out. These are the server-side lines that have to agree with the iceServers entry above; the full production build-out lives in Configuring Coturn for Production TURN Relay.
# coturn: the server half of the contract the client code assumes
listening-port=3478 # plain TURN, both UDP and TCP listeners
tls-listening-port=5349 # TURNS; terminate a real cert here, never self-signed
min-port=49152 # relayed transport addresses are drawn from this range
max-port=65535 # ~16k concurrent allocations per listening IP
external-ip=203.0.113.10/10.0.1.20 # PUBLIC/PRIVATE — required when coturn sits behind cloud NAT
use-auth-secret # long-term credentials derived from a shared HMAC secret
static-auth-secret=REPLACE_ME # same secret your credential service signs with
realm=turn.example.com # must match the realm advertised in the 401 challenge
user-quota=12 # allocations per user; a 3-URL ladder burns 3 per peer
total-quota=1200 # global ceiling before clients see 486
stale-nonce=600 # nonce rotation window in seconds
no-multicast-peers # refuse to relay toward multicast destinations
The external-ip line is the one that bites hardest on cloud VMs. Without it coturn writes the instance’s private address into the XOR-RELAYED-ADDRESS attribute, the browser faithfully advertises a relay candidate such as 10.0.1.20:49500, and the remote peer’s checks disappear into a VPC it has never heard of. The symptom is unmistakable once you know it: a candidate of type relay whose address sits in RFC 1918 space. user-quota is the quieter trap, because the three-URL ladder requests a separate allocation per URL — one two-party call can therefore hold six allocations across both peers, and a quota tuned as though each user needs one will start refusing calls precisely when demand peaks.
Reproduction Steps & Debugging Log Patterns
- Put a test client behind a known symmetric NAT (many mobile carriers and enterprise firewalls qualify) and run with
iceTransportPolicy: 'all'. - Log every candidate’s
type; confirm srflx candidates are generated but never nominated. - Inspect
pc.getStats()for the nominatedcandidate-pairand read itslocal-candidate/remote-candidatetypes. - Switch to
iceTransportPolicy: 'relay'and confirm the connection still succeeds — proving the relay, not luck, carries the media.
Expected log on a symmetric-NAT pair that correctly falls back:
// candidate srflx 203.0.113.7:51000 <- generated...
// candidate-pair (srflx/srflx) state: failed <- ...but never works
// candidate relay 198.51.100.4:49500
// candidate-pair (relay/relay) state: succeeded nominated: true
// iceConnectionState: connected
If you see only failed pairs and no relay candidate at all, TURN allocation failed — almost always errorCode 401 (bad or expired credentials) or 701 (the relay is unreachable on 3478/5349).
Reading the TURN error codes correctly
The most misread line in any relay debugging session is the first 401. TURN’s long-term credential mechanism has no way to carry credentials on the opening request: the client sends a bare Allocate, the server is required to answer 401 with a realm and a nonce, and the client repeats the request signed with them. Exactly one 401 per allocation is protocol-correct and appears in every healthy session. Two consecutive 401s against the same nonce is a genuine credential failure — nearly always clock skew larger than the validity window baked into the HMAC username, or a realm string that differs between coturn and the credential service. Counting them, rather than reacting to the first one, is what the event list in Reading chrome://webrtc-internals Dumps is for.
| Code | Server says | Real cause | Fix |
|---|---|---|---|
| 401 (once) | Unauthorized + nonce | Normal challenge handshake | Nothing — expected |
| 401 (repeated) | Unauthorized | Expired HMAC token, clock skew, realm mismatch | Re-issue credentials, sync NTP |
| 437 | Allocation Mismatch | Client reused a 5-tuple whose old allocation is still held | Let ICE re-gather on a fresh socket |
| 486 | Allocation Quota Reached | user-quota too low for a multi-URL ladder |
Raise quota to 3 per URL per peer |
| 701 | (no response) | 3478/5349 filtered, or relay down | Check firewall and relay health |
One failure mode never produces an error code at all: the allocation succeeds, the relay pair nominates, and then bytesReceived stays flat. That is a permission problem, not an allocation problem. Because permissions are scoped to the peer’s IP, anything that moves the remote peer to a new public address — most commonly a Wi-Fi to cellular transition, covered in Handling Wi-Fi to Cellular Network Handover — leaves the relay dropping inbound packets from an address it holds no permission for. Poll getStats() at 1 s intervals and watch the relay candidate pair: requestsSent climbing while responsesReceived stays pinned is the signature.
Browsers disagree about how much of this they will tell you. Chrome exposes relayProtocol on the local relay candidate, which is the only dependable way to learn which rung of the transport ladder actually carried the call — the pair’s protocol field describes the relay-to-peer leg, not the client-to-relay leg, so a UDP pair riding a TLS allocation looks like plain UDP if you read the wrong field. Firefox reports the equivalent detail in the about:webrtc ICE stats table rather than through the same stats field, so infer the tier from the candidate’s port (3478 versus 5349) when working from a Firefox log. Safari and iOS WKWebView fire icecandidateerror with far less useful errorText than Chrome, and in some embedded WKWebView contexts do not fire it at all, so validate a suspect credential from desktop Chrome before concluding the iOS client is at fault.
Common Implementation Mistakes
- Shipping STUN-only
iceServers. Without a TURN entry, symmetric-NAT users have no working path and fail silently after gathering completes. - Relying on UDP transport alone. Networks that block UDP also block your TURN relay unless you offer
transport=tcpand a TLS endpoint on 5349. - Stale TURN credentials. Long-lived static credentials get revoked or expire; issue short-lived tokens as described in Time-Limited TURN Credentials with HMAC and refresh before they lapse, or
addIceCandidateof the relay fails with 401. - Forgetting
restartIce()on credential rotation. When a token expires mid-call the relay drops, so re-gather with fresh credentials by Triggering an ICE Restart Without Dropping Media (max 3 attempts) rather than tearing down the call. - Testing only on cone NAT. A connection that works on your home router proves nothing about symmetric NAT — force
iceTransportPolicy: 'relay'in CI to exercise the relay path deterministically. - Running one relay region for a global user base. A relay in Frankfurt serving two peers in Sydney routes the media the long way twice; regional placement is worth 40–60% of connect latency, and the same reasoning behind Choosing STUN Server Regions for Latency applies with more force to relays, which carry every packet rather than one binding.
- Tearing down on the first
disconnected. A relayed path crossing a mobile network dips intodisconnectedroutinely; treating that as terminal restarts calls that would have recovered on their own within a few seconds, and the distinction is worth internalising from Disconnected vs Failed ICE States.
FAQ
Can two peers both behind symmetric NAT ever connect directly?
No. Neither can predict the external port the other’s NAT will open for the specific destination, so direct connectivity checks always fail. A TURN relay is mandatory for that pair.
How do I know whether a user is behind symmetric NAT?
Issue STUN binding requests to two different server addresses; if the reflexive mapping (IP:port) differs between them, the NAT is symmetric. Fleet-wide, the same signal shows up as a drop in binding health, which is why Monitoring STUN Binding Success Rates is the cheapest way to size your relay demand. In practice it is simpler to always provision TURN and let ICE fall back automatically.
Does forcing iceTransportPolicy: 'relay' hurt normal users?
Yes — it adds 20–40 ms of latency and routes all media through your servers even when a direct path was available. Use it for diagnostics and strict-compliance environments only; default to 'all'.
Do both peers need TURN configured, or is one relay allocation enough?
One is enough to connect the pair. A relayed transport address is a fixed public endpoint, so a peer behind symmetric NAT can send to it directly; the relay creates a permission for whatever source IP that peer’s NAT presents, and because permissions ignore ports, the peer’s per-destination remapping is irrelevant. The resulting relay/srflx pair carries media through a single allocation. Configuring both sides anyway is still the right default: it doubles the chance that at least one peer reaches a relay through whatever transport tier its network permits, and it lets ICE nominate whichever leg is shorter.
How much relay capacity does a symmetric-NAT-heavy user base need?
Size it on ports and egress separately. The 49152–65535 range yields roughly 16,000 concurrent allocations per listening IP, which is rarely the binding constraint. Bandwidth is: the relay both receives and re-sends every packet, so a single 2 Mbps video stream consumes about 4 Mbps of relay throughput, and a 1 Gbps interface saturates near 250 relayed streams before CPU ever becomes interesting. With 8–20% of connections needing a relay, a 5,000-concurrent-stream service should plan for 400–1,000 relayed streams and provision at least two regions rather than one oversized node.
Related: return to ICE Candidate Gathering & Filtering, and see WebRTC over CGNAT and ICE Candidate Trickle vs Bulk Gathering.