Most WebRTC apps get their TURN server credentials wrong the same way. Someone stands up coturn, creates one user, and pastes the username and password into the frontend config. It works on day one. A few months later the relay bill climbs, and the logs show traffic from clients that have never loaded your app.
The fix is old, well documented, and small: stop handing out a fixed password and mint short-lived TURN credentials on your backend for each session. This post explains how that scheme works, how to configure coturn for it, what TTL to pick, and how to confirm the relay path works before a customer on a locked-down network finds out it doesn’t.
Why a WebRTC TURN server needs credentials at all
A quick refresher on STUN vs TURN. STUN tells a client what its public address looks like from the outside, so it can try to reach the other side directly. TURN, defined in RFC 8656, is the fallback when no direct path exists: the client allocates a relay address on the TURN server, and media flows through it.
That makes a TURN server a bandwidth relay that anyone on the internet can reach. Without authentication it is an open proxy. With a single static password embedded in JavaScript, it is still an open proxy, just with one extra step, because anything shipped to a browser can be read by its user.
How often relay matters depends on your audience. Vendor estimates of the share of sessions that fall back to TURN commonly land in the 15 to 25 percent range, though that figure is not tied to a published study. A more concrete data point: callstats.io reported that 22% of failed calls in their dataset required some form of media relay. Corporate networks, hotel Wi-Fi, and networks that block UDP are where you see it most. Those are also the users most likely to be on an important call.
How ephemeral TURN server credentials work
The scheme most TURN servers support comes from an IETF draft, A REST API For Access To TURN Services. The draft is direct about the motivation: TURN long-term credentials are supposed to stay secret, and “in web applications, ensuring this secrecy is typically impossible.”
Instead of a stored password, your backend and the TURN server share one secret. When a client needs to connect, your backend builds:
- username:
<expiry unix timestamp>:<some id>, for example1792080600:3f2a9c1e-... - password:
base64(HMAC-SHA1(shared_secret, username))
The TURN server never needs a database lookup. It recomputes the HMAC from the username it receives, checks it matches, then checks that the timestamp in the username is still in the future. Because the expiry is inside the signed value, a client cannot extend it.
The id after the colon is yours to choose. Use something that identifies the session or participant. It shows up in TURN server logs, which makes it much easier to trace a burst of relay traffic back to a specific meeting.
Minting credentials on your backend
Here is the whole thing in TypeScript using Node’s built-in crypto module:
import { createHmac } from "node:crypto";
export function mintTurnCredentials(
sessionId: string,
sharedSecret: string,
ttlSeconds = 600,
) {
const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds;
// ':' is the separator, so keep it out of the id
const username = `${expiresAt}:${sessionId.replaceAll(":", "_")}`;
const credential = createHmac("sha1", sharedSecret)
.update(username)
.digest("base64");
return {
urls: [
"turn:turn.example.com:3478?transport=udp",
"turn:turn.example.com:3478?transport=tcp",
"turns:turn.example.com:5349?transport=tcp",
],
username,
credential,
};
}
Two rules make this safe:
- Only call it after you have authorized the user for the session. Credentials are proof that your backend let someone in, so issue them at the same point you issue any other join token.
- Never send the shared secret to the client. The client gets the derived username and password, nothing else.
Hosted TURN providers follow the same pattern. Cloudflare’s TURN service, for example, has you call an API from your server to generate short-lived ICE servers, and its docs tell you to keep the TURN key on the server side.
Configuring coturn
On the coturn side, enable the REST API mode and set the shared secret. The relevant options are described in the coturn man page:
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
realm=turn.example.com
lt-cred-mech
use-auth-secret
static-auth-secret=REPLACE_WITH_A_LONG_RANDOM_SECRET
# Don't let authenticated clients relay into your own network
no-multicast-peers
denied-peer-ip=10.0.0.0-10.255.255.255
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=127.0.0.0-127.255.255.255
denied-peer-ip=169.254.0.0-169.254.255.255
The denied-peer-ip lines matter as much as the credentials. A valid credential lets a client ask the TURN server to send packets to an arbitrary peer address. Without these rules, that includes your private subnets and cloud metadata endpoints. Enable Security’s coturn hardening guide calls relay abuse the top threat to TURN servers and recommends exactly this block list. If your SFU lives on a private address that clients reach through the relay, add a narrow allowed-peer-ip rule for it rather than dropping the whole list.
Choosing a TTL
The TTL is the main design decision, and the guidance varies:
- The IETF draft recommends one day.
- Cloudflare’s docs say the TTL should be longer than you expect anyone to use the TURN service, so it covers your longest call.
- Security guides push for shorter values so a leaked credential is useful for less time.
What actually happens at expiry depends on the server. On coturn, expiry stops new authentication. An allocation that is already established keeps running past the expiry time; a coturn contributor described this as expected behavior. With that behavior, a short TTL limits how long a leaked credential can be used to start new relays, without cutting off calls in progress.
The practical consequence: if your client reconnects (network change, laptop sleep, ICE restart), it may need fresh credentials. Fetch new ones from your backend on reconnect instead of caching the originals for hours. If you use a hosted TURN provider, check its docs for how it treats in-flight allocations before you shorten the TTL.
A reasonable default for a meeting product is 10 minutes, with credentials re-minted on every join or rejoin.
Testing the relay path
TURN problems hide well. On your office network, ICE almost always finds a direct path, so a misconfigured TURN server goes unnoticed until a customer behind a strict firewall can’t connect.
Force relay-only mode to test it. iceTransportPolicy: "relay" tells the browser to use only relayed candidates, per MDN’s RTCPeerConnection reference:
async function checkTurn(iceServers: RTCIceServer[]): Promise<boolean> {
const pc = new RTCPeerConnection({ iceServers, iceTransportPolicy: "relay" });
pc.createDataChannel("probe");
const gotRelay = new Promise<boolean>((resolve) => {
pc.onicecandidate = (e) => {
if (e.candidate?.type === "relay") resolve(true);
if (!e.candidate) resolve(false); // gathering finished
};
});
await pc.setLocalDescription(await pc.createOffer());
const timeout = new Promise<boolean>((r) => setTimeout(() => r(false), 5000));
const ok = await Promise.race([gotRelay, timeout]);
pc.close();
return ok;
}
If this returns false with freshly minted credentials, check in this order: the secret matches on both sides, server clocks are in sync (the expiry is a timestamp), and the TURN ports are open in your firewall. Run it once with only the turns: URL as well. TLS over TCP is the path that survives networks that block UDP entirely, and it is the one most often left unconfigured.
How CoveKit handles TURN credentials
CoveKit’s control plane, written in Rust, uses this same scheme. When a participant calls the public join endpoint and is admitted, the API signs a short-lived join token for the media server and, in the same response, returns an ice_servers list. If a TURN shared secret is configured, the TURN entry carries a username of <expiry>:<session id> and an HMAC-SHA1 credential. The TTL defaults to 600 seconds and can be configured between one minute and one day. The list includes UDP, TCP, and TLS (turns:) URLs.
Participants sitting in a waiting room get no credentials. The join response only includes ice_servers once the host admits them, so relay access follows meeting access.
With the SDK, you pass that list straight through:
import { CoveKitClient, CoveKitRoom } from "covekit";
const client = new CoveKitClient();
const session = await client.joinPublicMeeting("mtg_abc123", "Ada");
const room = new CoveKitRoom(client, {
onConnected: () => room.publishLocalMedia(true, true),
onRemoteTrack: (track, peerId, kind) => {
/* attach to a <video> or <audio> element */
},
onError: (err) => console.error(err),
});
// session.ice_servers holds STUN plus per-session TURN credentials
await room.join(session.token, session.ice_servers);
The room hands ice_servers to the send and receive transports it creates with mediasoup-client, so the browser can fall back to relay when it can’t reach the SFU directly. There are no TURN usernames or passwords in your frontend code, and nothing to rotate when someone views source.
If you’d rather not run coturn, write the HMAC code, and keep the block list current yourself, read the CoveKit docs to see how the join flow fits into your app.