TURN Server Configuration & Auth
TURN relays carry the media that direct peer-to-peer paths cannot. When symmetric NAT, carrier-grade NAT, or a corporate firewall blocks every host and srflx candidate pair, the relay is the only transport that still connects the call β the candidate-pair mechanics of that case are dissected in Traversing Symmetric NAT with TURN β and a misconfigured relay fails silently, surfacing only as a failed ICE state minutes into a session. This guide is part of the WebRTC Protocol Stack & Signaling Servers guide, and it walks through standing up an authenticated, production-grade TURN relay end to end: listener and port configuration, time-bound HMAC credentials, secure delivery to the browser, and the verification commands that prove the relay actually allocates before you ship it.
The goal is a relay that authenticates every Allocate request against a rotating shared secret, advertises a routable public address, listens on both UDP and TLS, and bounds per-user bandwidth so a single abusive client cannot exhaust the node. Two focused references extend this guide: Configuring Coturn for Production TURN Relay for the full turnserver.conf and OS-level tuning, and Time-Limited TURN Credentials with HMAC for the exact credential-signing math.
Step 1 β Provision listeners, ports, and public IP
A TURN relay needs three things reachable from the open internet: a control port for Allocate/Refresh messaging, a TLS port for clients behind deep-packet-inspection proxies, and a contiguous block of relay ports for the actual media. The control port is 3478 (shared with STUN), the TLS port is 5349 (turns://), and the relay range is conventionally 49152β65535 β the IANA ephemeral range. Many production deployments also bind TLS on 443 so the relay is indistinguishable from ordinary HTTPS to a filtering proxy, a listener whose certificate and ALPN details are worked through in Forcing TURN over TCP 443 on Locked-Down Networks.
The single most common deployment failure is a missing or inverted external-ip. On any cloud instance the OS sees only the private RFC 1918 address; coturn must be told the public address explicitly or it will advertise an unroutable relay candidate and every call behind symmetric NAT will fail. The format is PUBLIC_IP/PRIVATE_IP β public first.
# /etc/turnserver.conf β listeners and addressing
listening-ip=0.0.0.0 # bind all interfaces; coturn picks per-request
listening-port=3478 # STUN + plain TURN (UDP and TCP)
tls-listening-port=5349 # turns:// over TLS
external-ip=203.0.113.10/10.0.1.5 # PUBLIC/PRIVATE β public address first
realm=turn.example.com # authentication realm advertised to clients
server-name=turn.example.com
min-port=49152 # start of the relay media port range
max-port=65535 # end of the relay range β open BOTH in the firewall
fingerprint # add FINGERPRINT to messages (strict clients require it)
Open the relay range in your security group as well as 3478/5349/443. Media flows through min-portβmax-port, not through 3478 β a firewall that allows only 3478 lets allocation succeed yet drops every media packet, producing a connection that negotiates and then goes silent. The full annotated config, including no-tcp-relay trade-offs and kernel tuning, lives in Configuring Coturn for Production TURN Relay.
Port arithmetic and the capacity ceiling of one relay address
Each successful Allocate reserves exactly one relayed transport address β one UDP port taken from the min-portβmax-port window β and holds it for the allocation lifetime, 600 seconds by default, extended by the Refresh requests browsers send well inside that window. That one-port-per-allocation rule sets a hard ceiling: 49152β65535 is 16,384 ports, so a single listening address tops out at 16,384 concurrent allocations no matter how much CPU or NIC headroom the box has. A two-party call in which both endpoints are relay-only consumes two allocations, and a conference in which every participant leg terminates on a media server consumes one allocation per leg, so the honest planning figure is nearer 8,000 relayed calls per address. Scale past that by adding addresses β coturn accepts repeated relay-ip lines, one per bound interface β rather than by widening the range. You cannot widen above 65535, and reaching below 49152 collides with the ephemeral sockets the kernel hands to the machineβs own outbound connections, which shows up as sporadic allocation failures under load that no amount of firewall auditing explains.
Bandwidth, not ports, is usually the binding constraint first. A relay is store-and-forward at the transport layer, so every relayed stream is counted twice on the nodeβs interface: ingress from the sender and egress to the receiver. One 500 kbps video stream relayed in both directions is 2 Mbps of relay traffic, while a 24β32 kbps Opus stream is rounding error. At a typical 10β20% relay rate across a consumer user base, a node carrying 1,000 concurrent video calls sees on the order of 200β400 Mbps sustained β which is why relay egress, not licence cost, dominates the economics of a fully relayed topology and why the SFU vs MCU Cost & Quality Trade-offs analysis matters even to teams who think of TURN as a corner case.
Permissions and channel bindings: why allocation alone is not connectivity
Reserving the port is only the first half of the handshake. Before any peer traffic crosses the relay, the client must install a permission for each remote address it intends to talk to via CreatePermission, and each permission expires 300 seconds after its last refresh. coturn discards inbound packets from any peer address without a live permission, and it does so without an error response β on the wire that is indistinguishable from packet loss. The signature is an asymmetric candidate pair: bytesSent climbing steadily while bytesReceived stays pinned at its last value, followed a few seconds later by the connection sliding into disconnected. If you have never had to separate that pattern from ordinary loss, the state-transition rules in Disconnected vs Failed ICE States are the fastest way to tell a lapsed permission from a genuinely dead path.
Once media is flowing, the client upgrades from Send/Data indications to a channel binding. The difference is framing overhead: a Send indication wraps every payload in a 36-byte STUN header, whereas ChannelData uses a 4-byte header. At the roughly 50 packets per second of a standard 20 ms Opus stream that is about 12.8 kbps saved per direction β a third of the audio stream itself, and the reason a relayed audio-only call costs far less than the naive STUN-framing estimate suggests. Chrome and Firefox both issue ChannelBind automatically once a permission exists; there is no JavaScript control surface for it. The practical consequence is for whoever reads the packet capture: relayed media on port 49152+ that does not begin with a STUN magic cookie is not corrupt, it is ChannelData, and a capture filter that keys on the STUN cookie will show you an empty relay carrying a healthy call.
Step 2 β Enable HMAC authentication and a rotating secret
Never ship static long-term usernames and passwords. A static credential embedded in a client bundle is scraped within hours and replayed to mine free relay bandwidth. The production model is the TURN REST API scheme (draft-uberti-behave-turn-rest): the server holds a single static-auth-secret, and your backend mints short-lived credentials by signing a timestamped username with HMAC-SHA1. The relay recomputes the same HMAC at allocation time, so no per-user state is stored and expired credentials are rejected automatically.
Enable the long-term credential mechanism and register the shared secret. stale-nonce forces nonce rotation and blocks replay of a captured handshake.
# /etc/turnserver.conf β authentication
lt-cred-mech # enable long-term credential mechanism (required for HMAC)
use-auth-secret # use the REST-API shared-secret model, not per-user DB rows
static-auth-secret=BASE64_32_BYTE_SECRET # the HMAC key; rotate via env injection
stale-nonce=600 # force a fresh nonce every 600s to defeat replay
The credential the browser presents is username = ${expiryUnixTimestamp}:${userId} and credential = base64(HMAC_SHA1(username, static-auth-secret)). Keep the TTL between 1 and 24 hours: shorter windows shrink the blast radius of a leaked token but force re-fetches on long calls and ICE restarts. coturn supports multiple static-auth-secret lines simultaneously, which is how you rotate with zero downtime β append the new secret, reload, and remove the old one after the longest outstanding TTL expires. The exact signing code, padding rules, and the failure log lines are covered in Time-Limited TURN Credentials with HMAC.
Bound the blast radius with quotas and peer filtering
Authentication answers who may allocate; it says nothing about how much they may allocate or where the relay will forward their packets. Both gaps are exploitable with a perfectly valid credential. Left unbounded, a relay will forward to any destination the operatorβs own routing table can reach β including RFC 1918 space and the cloud metadata endpoint at 169.254.169.254. An attacker holding one legitimately minted, unexpired credential can therefore use your relay as a server-side request forgery pivot into the VPC that hosts it, reaching internal admin ports that are firewalled from the internet but wide open to the relayβs own NIC. Deny-list the private ranges explicitly; do not assume the defaults cover it.
# /etc/turnserver.conf β abuse containment
user-quota=6 # concurrent allocations per credential: dual-stack + ICE-restart headroom
total-quota=1200 # server-wide allocation cap, deliberately under the 16384-port ceiling
max-bps=800000 # per-allocation ceiling: 500 kbps video + 32 kbps Opus + protocol overhead
no-multicast-peers # never relay toward multicast destinations
denied-peer-ip=10.0.0.0-10.255.255.255 # keep the relay out of your own VPC
denied-peer-ip=172.16.0.0-172.31.255.255
denied-peer-ip=192.168.0.0-192.168.255.255
denied-peer-ip=169.254.0.0-169.254.255.255 # blocks the cloud metadata endpoint
Size user-quota against what one honest browser actually asks for, not against one call. A client that lists a UDP relay URL and a TLS relay URL gathers a relay candidate for each, and on a dual-stack host it may allocate over both IPv4 and IPv6 β four allocations from a single page load before anyone has restarted ICE. Set the quota to three or four times your per-URL count; anything tighter and a legitimate reconnect trips error 486 (Allocation Quota Reached) at exactly the moment the user is already suffering. max-bps is a per-allocation hard drop, not a shaper: coturn discards the excess rather than queuing it, so a cap set at the nominal encoder target converts every keyframe burst into visible loss. Leave 30β50% headroom above the top simulcast layer β 800 kbps for a 500 kbps top layer plus audio is a reasonable starting point, and you tighten it only after watching real outboundRtp peaks.
Step 3 β Deliver credentials and build the ICE server array
Credentials must reach the browser over an authenticated, encrypted channel before RTCPeerConnection is constructed β typically the same channel you already use for SDP, covered in WebSocket Signaling Implementation. Fetch them just-in-time from a backend endpoint that performs the HMAC signing server-side; the static secret must never touch the client.
Construct the iceServers array with both a turn: URL on 3478 and a turns: TLS URL, so the ICE agent can fall back to TLS-over-TCP when UDP is blocked. Pair the relay with a STUN Server Deployment Strategies endpoint so cheap srflx paths are tried before the relay is ever allocated.
// Fetch ephemeral credentials, then build the peer connection
const res = await fetch('/api/turn-credentials', { credentials: 'include' });
const creds = await res.json(); // { username, credential, ttl }
const iceServers = [
{ urls: 'stun:stun.example.com:3478' }, // try srflx first β no relay bandwidth cost
{
urls: 'turn:turn.example.com:3478?transport=udp', // primary relay over UDP
username: creds.username,
credential: creds.credential
},
{
urls: 'turns:turn.example.com:5349?transport=tcp', // TLS fallback for DPI proxies
username: creds.username,
credential: creds.credential
}
];
const pc = new RTCPeerConnection({
iceServers,
iceTransportPolicy: 'all' // set 'relay' only to force relay-only paths for testing
});
Refreshing credentials without rebuilding the peer connection
RTCPeerConnection copies the iceServers array at construction time. Mutating the array object you passed in has no effect whatsoever on the live connection β a mistake that survives review because nothing throws and nothing logs. The only supported path is setConfiguration(), and even that applies exclusively to the next gathering pass, so a refreshed credential does not touch the allocations already in flight; it is there so that the next restartIce() has something valid to authenticate with. Schedule the re-fetch at roughly 75% of the advertised TTL so a call that outlives its credential always has a fresh one staged before it is needed.
// Keep TURN credentials fresh across long sessions and ICE restarts
let refreshTimer;
async function applyFreshTurnCredentials(pc) {
const res = await fetch('/api/turn-credentials', { credentials: 'include' });
const creds = await res.json(); // { username, credential, ttl } β ttl in seconds
const cfg = pc.getConfiguration(); // the snapshot taken at construction
cfg.iceServers = cfg.iceServers.map(s =>
String(s.urls).startsWith('turn') // leave the stun: entry alone β it needs no auth
? { ...s, username: creds.username, credential: creds.credential }
: s
);
pc.setConfiguration(cfg); // takes effect on the NEXT gathering pass only
clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => applyFreshTurnCredentials(pc), creds.ttl * 750); // 75% of TTL, in ms
}
pc.addEventListener('icecandidateerror', (e) => {
// 401 credential rejected, 486 per-user quota reached, 508 relay ports exhausted
if (e.errorCode === 401) applyFreshTurnCredentials(pc); // expired token or clock skew
console.warn('ICE server error', e.url, e.errorCode, e.errorText);
});
The 401 branch exists mainly to catch clock skew, which is the most under-diagnosed cause of intermittent TURN failure. The expiry embedded in the username is compared against the relayβs clock, not the signerβs, so a backend running two minutes behind mints credentials that the relay considers already valid but that expire two minutes early β producing allocations that succeed for most users and fail for the ones whose call happened to start near the boundary. Keep both machines on NTP, sign from server time rather than any client-supplied timestamp, and add a 300-second safety margin to the TTL you advertise to the browser so the refresh timer always fires before the relayβs view of expiry. Chrome records every one of these rejections in the event log of a chrome://webrtc-internals dump, where a burst of iceCandidateError entries with errorText: "Unauthorized" against a single relay URL is unambiguous; the anatomy of that dump is broken down in Reading chrome://webrtc-internals Dumps.
Cache the credential object for the duration of ttl and reuse it across restarts β Triggering an ICE Restart Without Dropping Media walks through why the existing allocation survives β because re-fetching on every restartIce() adds avoidable signalling round-trips. To force the relay path during development and confirm it works in isolation, set iceTransportPolicy: 'relay' β this suppresses host and srflx candidates so only relay pairs remain.
Step 4 β Verification
Prove allocation works from the server before trusting it in production. coturn ships turnutils_uclient, which performs a real Allocate and relays test packets using a credential you supply.
# Mint a credential with your backend, then drive a real allocation through the relay
turnutils_uclient \
-u "1780000000:alice" \
-w "$(printf '%s' '1780000000:alice' \
| openssl dgst -sha1 -hmac "$TURN_SECRET" -binary | base64)" \
-y -m 10 turn.example.com # -y verbose, -m 10 send 10 relayed messages
A healthy run logs allocate sent, allocate response received, and a relayed address in the 49152β65535 range. On the server, tail the log and confirm the matching allocation line.
# Watch coturn confirm or reject the allocation in real time
journalctl -u coturn -f \
| grep -E "relayed address .* (allocated|not allocated)"
# success: INFO: session ...: relayed address 203.0.113.10:51234 allocated
# failure: ERROR: session ...: relayed address ... not allocated
Cross-check that the relayed IP equals the public half of your external-ip. From the browser side, poll pc.getStats() and confirm the selected candidate pair has localCandidateType === 'relay' with non-zero bytesSent/bytesReceived β that is the only definitive proof the relay is carrying media rather than just answering allocations.
Decoding allocation failures by STUN error code
When turnutils_uclient or the browser reports a rejection, the STUN error code names the fault precisely. Memorising the six that actually occur in production turns a multi-hour investigation into a one-line diagnosis.
| Code | Name | What it really means | Fix |
|---|---|---|---|
| 401 | Unauthorized | Expected on the first Allocate β the client must retry with the nonce and realm from this response. Persistent 401 means expired credential, clock skew, or a digest mismatch. |
Verify NTP on both hosts; confirm use-auth-secret and the exact secret bytes match the signer. |
| 403 | Forbidden | The peer address the client asked to reach is on a denied-peer-ip range. |
Intended for internal ranges. If it fires on real peers, your deny-list is too broad. |
| 437 | Allocation Mismatch | The clientβs 5-tuple already has an allocation, or the relay never saw the allocation the client thinks it owns. | Usually a load balancer rewriting source addresses, or a client socket rebind. |
| 438 | Stale Nonce | The nonce has aged past stale-nonce. Normal and self-healing β the client retries with the new nonce. |
Only a problem if the rate is high; raise stale-nonce toward 600 s. |
| 486 | Allocation Quota Reached | This credential has hit user-quota. |
Raise the quota to cover multi-URL, dual-stack gathering, or mint per-session user ids. |
| 508 | Insufficient Capacity | No relay port is free, or total-quota is hit. |
Add relay addresses; check for leaked allocations that never expired. |
Two of these mislead reliably. Error 437 almost never means what its name suggests β in a WebRTC deployment it is nearly always infrastructure, because TURN binds an allocation to the clientβs exact source address and port, and any layer-7 proxy or application load balancer in front of 5349 presents its own address for every client. The relay then sees thousands of clients collapsing into one 5-tuple and rejects all but the first. Terminate TURN TLS on the relay itself, or front it with a layer-4 load balancer that preserves the client address; never with an HTTP-aware proxy. Error 508 is the one that appears months after launch: allocations that are never explicitly released linger for the full 600-second lifetime, so a client crash loop can strand ports faster than they expire, and the port pool drains while CPU and bandwidth graphs look perfectly healthy. Alert on active allocations as a percentage of max-port β min-port, not on CPU, and treat sustained occupancy above 60% as the signal to add an address.
Edge Cases & Browser Quirks
- Chrome caps ICE gathering at roughly 10β15 seconds and limits concurrent candidate pairs. If TURN allocation is slow, the connection can reach
failedbefore the relay pair is even tried; keep allocation latency low by deploying relays in-region with your STUN nodes. - Firefox historically restricts the local UDP socket range to 49152β65535 on some platforms, which is cosmetic for the relay but matters when you reason about why a
hostcandidate range differs across browsers inabout:webrtc. - Safari (WebKit) is strict about the
turns:certificate chain β a relay presenting an incomplete chain that Chrome tolerates will fail TLS silently on Safari, so always serve the full intermediate bundle on 5349/443. - All browsers treat a
turn:URL with a missing?transport=parameter as UDP. Enterprise networks that block UDP need an explicit?transport=tcp(preferably TLS on 443) or the relay is unreachable for those users. - Mobile clients on CGNAT, the address-sharing regime detailed in WebRTC over CGNAT, may refresh their NAT binding in under 30 seconds; the relayβs
Refreshhandling keeps the allocation alive, but astale-noncevalue far below 600 seconds can force avoidable re-authentication churn on flaky links. - Every relay URL costs a separate allocation. Chrome gathers one relay candidate per URL and per transport, so listing two relay hostnames each with UDP, TCP, and TLS variants issues six
Allocaterequests inside the same 10β15 second gathering budget. Each one consumes a slot againstuser-quotaand each one delays the moment a usable pair is nominated. One relay hostname with one URL per transport is the configuration that both connects fastest and stays inside sane quotas. - Firefox validates the
turns:hostname against the certificate SAN and will not accept a certificate issued to an IP address, so a relay reachable only by literal address cannot serve TLS to Firefox at all. Chrome is equally strict here in current versions; only the failure reporting differs, with Firefox logging the TLS error intoabout:webrtcwhile Chrome surfaces it as a bareicecandidateerrorwith no TLS detail. - Safari and iOS WKWebView tear down allocations on backgrounding. The OS suspends the networking stack within a few seconds of the app leaving the foreground, so
Refreshstops and the relay reclaims the port after the 600-second lifetime β but the JavaScript side often resumes far sooner and believes its allocation is intact. Treat foreground resume as a trigger to checkiceConnectionStateand restart ICE if it is anything butconnected; the same reasoning applies to the radio changes covered in Handling Wi-Fi to Cellular Network Handover. icecandidateerrorfield coverage differs. Chrome populatesurl,address,port,errorCode, anderrorText, which is enough to attribute a failure to one relay in a multi-region pool. Firefox reports the event with a thinner payload and Safariβs Web Inspector has nowebrtc-internalsequivalent at all, so validate every relay change on Chrome first and use the other two only to confirm parity.
Common Implementation Mistakes
- Inverted or missing
external-ip. WritingPRIVATE/PUBLICor omitting it entirely makes coturn advertise an RFC 1918 relay candidate that no remote peer can route β calls fail silently behind symmetric NAT. - Firewall opens 3478 but not the relay range. Allocation succeeds, media dies. Always open
min-portβmax-port(49152β65535) for both UDP and TCP. static-auth-secretwithoutlt-cred-mech/use-auth-secret. HMAC credentials are silently rejected because the long-term mechanism is never engaged.- No TLS fallback. Relying on UDP-only
turn:leaves every corporate user, where outbound is restricted to 80/443, with no working path. Always includeturns://β¦?transport=tcp. - Signing credentials in the browser. Any code path that puts
static-auth-secretin the client bundle hands attackers an unlimited credential factory. Sign only on the backend. - Omitting
stale-nonce. Without it a captured handshake can be replayed indefinitely. Enable it alongsidelt-cred-mech. - No
denied-peer-ipfor private ranges. An authenticated relay with an open forwarding policy is a routable path into the VPC it runs in, including the metadata endpoint. Deny 10/8, 172.16/12, 192.168/16 and 169.254/16 unless you have a specific reason not to. - Fronting the relay with an application load balancer. Anything that rewrites the client source address collapses every allocation onto one 5-tuple and produces error 437 for all but the first client. Terminate TLS on the relay or use a layer-4 balancer that preserves the source address.
- Setting
max-bpsat the encoderβs nominal target. coturn drops rather than shapes, so keyframe bursts land as loss. Cap 30β50% above the top simulcast layer. - Assuming a fresh credential rescues a live connection. Replacing
iceServersonly affects the next gathering pass; an allocation already rejected stays rejected until you callrestartIce(). - Monitoring CPU instead of port occupancy. Relay ports exhaust silently and long before the CPU graph moves. Export active allocations against the
min-portβmax-portspan and alert at 60%.
FAQ
When should I use static long-term credentials instead of ephemeral HMAC ones?
Only for isolated internal test rigs where the relay is not internet-exposed. Any production relay must use the REST-API HMAC model (use-auth-secret + static-auth-secret) with a TTL between 1 and 24 hours, because static credentials are trivially scraped from client traffic and replayed.
How do I confirm a call is actually using the relay rather than a direct path?
On the client, poll pc.getStats() for the succeeded candidate pair and check localCandidateType === 'relay' with rising bytesSent. On the server, grep coturn logs for relayed address β¦ allocated and track active session counts.
Why does the relay work on home networks but fail on corporate ones?
Corporate firewalls commonly block all UDP and restrict outbound to 80/443. Without a turns://β¦?transport=tcp listener on 443, those users have no reachable transport. Bind TLS on 443 and validate with turnutils_uclient over TLS before rollout.
Can I rotate the shared secret without dropping live calls?
Yes. coturn accepts multiple static-auth-secret entries. Add the new secret, systemctl reload coturn, let outstanding credentials expire by their TTL, then remove the old secret β no active allocation is interrupted.
How many concurrent calls can one relay node carry? Three ceilings apply and the lowest one wins. Ports cap you at 16,384 allocations per listening address, which is roughly 8,000 two-party relayed calls. Bandwidth usually binds first: a relayed 500 kbps video stream costs 1 Mbps of node throughput in each direction, so 1,000 concurrent video calls is 200β400 Mbps sustained. CPU is rarely the limit for plain UDP relaying but becomes relevant once most traffic arrives over TLS on 443, where per-connection crypto is real work. Instrument all three and scale on whichever crosses 60% first.
Does the relay port range need to be open for TCP as well as UDP? The peer-facing side of a browser allocation is always UDP β browsers never request the TCP allocations that RFC 6062 defines, so traffic in 49152β65535 is UDP in practice. The client-facing side is what varies between UDP 3478, TCP 3478, and TLS 443, and those are separate listener ports, not part of the relay range. Opening TCP across the relay range as well is harmless and is what most security-group templates do, but the UDP rule is the one whose absence breaks calls.
Is it ever right to force iceTransportPolicy: 'relay' in production?
Occasionally, and always as a deliberate trade. Forcing the relay adds 20β40 ms one-way, doubles your egress bill for those sessions, and removes the direct path that would otherwise carry most calls. In exchange you get a single predictable network path that is far easier to debug and support, and you stop exposing participantsβ local and reflexive addresses to each other β which is why privacy-sensitive products and some regulated deployments accept the cost. For everything else, leave the policy at all and let ICE prefer the cheap path.
Related: this guide sits under WebRTC Protocol Stack & Signaling Servers; pair it with Configuring Coturn for Production TURN Relay, Time-Limited TURN Credentials with HMAC, STUN Server Deployment Strategies, and ICE Candidate Gathering & Filtering.