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 β 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.
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.
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.
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
});
Cache the credential object for the duration of ttl and reuse it across ICE restarts; 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.
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 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.
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.
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.
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.