Generating Time-Limited TURN Credentials with HMAC
This page covers the exact mechanics of minting ephemeral TURN credentials with the REST-API scheme: how to compose the timestamped username, sign it with HMAC-SHA1 over the relayβs shared secret, pick a TTL, and verify the result against a running coturn. It is part of the TURN Server Configuration & Auth section within the WebRTC Protocol Stack & Signaling Servers guide. The precise decision here is how to hand a browser a credential that the relay will accept for a bounded window without ever storing per-user state β and without letting the signing secret leave your backend.
Context & Trade-offs
Static long-term TURN usernames and passwords are a liability: embedded in client JavaScript, they are scraped within hours and replayed to mine free relay bandwidth. That matters most for the sessions that cannot avoid the relay at all β the endpoints behind Traversing Symmetric NAT with TURN, where every byte of media is paid for by you. The REST-API model (draft-uberti-behave-turn-rest) removes per-user secrets entirely. coturn holds a single static-auth-secret; your backend derives a credential by signing a username that encodes an expiry timestamp. At allocation time coturn recomputes the same HMAC-SHA1 and compares β if the timestamp is in the past or the signature does not match, the Allocate is rejected. No database lookup, no shared state across relay nodes.
The username format is ${expiryUnixTimestamp}:${userId} and the credential is base64( HMAC_SHA1( username, static-auth-secret ) ). The TTL is the only real tuning knob and it is a direct security-versus-ergonomics trade. A 1-hour TTL shrinks the window in which a leaked credential is useful but forces re-fetches on long calls and after every ICE restart. A 24-hour TTL eliminates that churn but widens exposure. Most production deployments land at 1β12 hours and cache the credential client-side for its lifetime so the recovery path in Triggering an ICE Restart Without Dropping Media does not stall on a fresh signalling round-trip. The userId half is informational β coturn does not validate it against any user store β so use it for log correlation, not authorization.
Why the digest is a password, not a token
The base64 digest is never sent to the relay as a bearer value, and understanding that removes most of the schemeβs mystery. TURN inherits the STUN long-term credential mechanism, so the browser treats the digest exactly as it would a human-chosen password: it derives the key MD5(username ":" realm ":" credential) and uses that key to compute the MESSAGE-INTEGRITY attribute β itself an HMAC-SHA1 β over the entire Allocate message. coturn reverses the chain in one pass: it splits the presented username at the first colon, signs the whole username with static-auth-secret to regenerate the password it would have issued, folds that into the same MD5 key, and recomputes the message integrity. Two nested HMAC-SHA1 operations run per authenticated request, and neither one requires a lookup.
Two consequences fall out of that derivation. The first is that the realm string is part of the key: the browser folds in whatever realm coturn advertised in its 401 challenge, byte for byte, so editing the realm directive invalidates every credential currently cached in a browser even though timestamps and secret are untouched. The second is that SHA-1βs collision weakness is irrelevant here β the digest functions as a 160-bit shared secret, not as a signature an attacker must forge. The realistic threat stays what it always was: scraping a live credential out of a browser session before it expires.
Clock skew and the mid-call expiry boundary
coturn compares the username prefix against its own wall clock, not the minting hostβs. If the relayβs clock runs ahead, credentials are rejected before the TTL you advertised has elapsed; if the backend runs ahead, they outlive it. Keep both hosts on NTP with a measured offset under 100 ms, and enforce a TTL floor of roughly 600 seconds so drift can never consume a meaningful fraction of the window.
The subtler trap is that the expiry is re-evaluated on every authenticated request, not only the first. Refresh and CreatePermission carry message integrity too, so coturn re-derives the password β and re-checks the timestamp β each time. A credential that lapses mid-call therefore does not fail at the moment of expiry; the allocation keeps forwarding until the clientβs next Refresh, at which point the relay answers 401 and the allocation runs out its remaining lifetime and disappears. The symptom is media that stops while the connection sits in connected for several seconds before falling to disconnected, easy to misread as a network event rather than an auth event β Disconnected vs Failed ICE States covers how to separate the two. Endpoints that can never leave the relay path, such as WebRTC over CGNAT clients, lose the call outright. Size the TTL above your longest expected session, or re-mint and re-apply before the boundary.
Minimal Runnable Implementation
The signing must happen server-side; the static secret must never reach the browser. This Express endpoint mints a credential and returns only the public fields.
const crypto = require('crypto');
const express = require('express');
const app = express();
// Compose username = expiry:userId, credential = base64 HMAC-SHA1(username, secret)
function generateTurnCredentials(userId, secret, ttlSeconds = 3600) {
const expiry = Math.floor(Date.now() / 1000) + ttlSeconds; // absolute UNIX expiry
const username = `${expiry}:${userId}`; // coturn parses the prefix
const credential = crypto
.createHmac('sha1', secret) // key = the relay's static-auth-secret
.update(username) // sign the FULL username string, exactly
.digest('base64'); // base64, NOT hex β coturn expects base64
return { username, credential, ttl: ttlSeconds };
}
app.get('/api/turn-credentials', (req, res) => {
const userId = req.session?.userId || 'anon'; // identify for log correlation only
const creds = generateTurnCredentials(userId, process.env.TURN_SECRET, 3600);
res.set('Cache-Control', 'no-store'); // never let a proxy cache a credential
res.json({
username: creds.username,
credential: creds.credential,
ttl: creds.ttl,
urls: [ // hand the client both transports
'turn:turn.example.com:3478?transport=udp',
'turns:turn.example.com:5349?transport=tcp'
]
});
});
app.listen(8081);
The relay side needs only the matching directives β lt-cred-mech, use-auth-secret, and the same static-auth-secret β covered in full in Configuring Coturn for Production TURN Relay. The client then drops username and credential straight into its iceServers entries; delivery happens over the encrypted channel described in WebSocket Signaling Implementation, and the relay itself is reached only after STUN Server Deployment Strategies have exhausted the cheaper srflx path. The same credential works unchanged on the 443 listener you need for Forcing TURN over TCP 443 on Locked-Down Networks, so add that URL to the urls array rather than minting a second pair.
Caching the credential and swapping it in mid-session
The client half is a cache with a refresh margin, not a fetch per peer connection: one credential per tab, refreshed a few minutes before expiry and pushed into the live connection with setConfiguration().
let cached = null; // { username, credential, expiresAt }
async function getIceServers() {
const now = Date.now() / 1000;
// Re-mint 300 s early so a slow round-trip can never race the expiry
if (!cached || cached.expiresAt - now < 300) {
const r = await fetch('/api/turn-credentials', { credentials: 'same-origin' });
const c = await r.json();
cached = { ...c, expiresAt: Number(c.username.split(':')[0]) }; // trust the signed prefix
}
return [
{ urls: 'stun:stun.example.com:3478' }, // srflx first β relay is the fallback
{ urls: cached.urls, username: cached.username, credential: cached.credential }
];
}
// Refresh the relay credentials on a long call without tearing the session down
async function refreshRelayAuth(pc) {
pc.setConfiguration({ iceServers: await getIceServers(), iceTransportPolicy: 'all' });
pc.restartIce(); // new servers apply to the next gathering only
}
setConfiguration() never re-authenticates an existing allocation β it only changes which servers the next gathering pass contacts. Chrome has honoured a changed iceServers list since Chrome 77, and Firefox and Safari both accept the call but defer the new list until gathering restarts, so the paired restartIce() is mandatory on every engine rather than a Chrome workaround. Calling setConfiguration() alone is the most common reason a βcredential refreshβ appears to do nothing.
Reproduction Steps & Debugging Log Patterns
- Reproduce the signature on the command line so you can diff it against the Node output. The two must be byte-identical:
# Independently recompute the credential for a fixed username
printf '%s' '1780000000:alice' \
| openssl dgst -sha1 -hmac "$TURN_SECRET" -binary | base64
- Drive a real allocation with that pair using coturnβs test client:
turnutils_uclient -u "1780000000:alice" -w "<base64-credential>" \
-y -m 5 turn.example.com # -y verbose, -m 5 relay five messages
- On success the log shows
INFO: session <id>: realm <β¦> user <1780000000:alice>: incoming packet ALLOCATE processed, successfollowed byrelayed address β¦ allocated. - On an expired or wrong credential the log shows
401thenerror 401 (Unauthorized)β confirm the username prefix is a future UNIX timestamp and that the secret matches exactly. A438 (Stale Nonce)is normal mid-handshake; the client retries with the fresh nonce automatically. - A common mismatch is hex-vs-base64: if the credential validates with
opensslbut coturn rejects it, confirm you passed.digest('base64')and not'hex'.
Walk the rejection reasons in the order below β each check is cheap and eliminates a whole class of cause before the next one.
Relay-side settings that change the verdict
A handful of coturn directives decide when a credential is re-examined and how much a valid one may consume. stale-nonce sets how long a nonce stays usable β 600 seconds by default β and every rotation forces the client through a fresh 438 and re-authentication, which is precisely where an expired timestamp gets caught. Lowering it tightens revocation at the cost of one extra round-trip per rotation.
# /etc/turnserver.conf β the directives that govern ephemeral credential checking
lt-cred-mech # long-term credentials; required by the REST scheme
use-auth-secret # parse username as expiry:userId and derive the password
static-auth-secret=<32+ random bytes, hex or base64>
realm=turn.example.com # folded into the key β changing it invalidates cached creds
stale-nonce=600 # nonce lifetime; each rotation re-checks the expiry
max-allocate-lifetime=600 # cap on a single allocation before a Refresh is required
user-quota=0 # per-username caps are useless here; see the note below
total-quota=1200 # server-wide concurrent-session ceiling still applies
user-quota deserves a warning. It counts against the username string, and every mint produces a different username, so a client that simply re-fetches gets a clean bucket. Bandwidth abuse has to be capped where identity actually lives: rate-limit mints per account on the backend (one per 30 minutes is generous for a caching client) and reject requests from sessions without a logged-in principal.
Reading the failure from the browser side
The relay log is authoritative, but you often only have the client. Chrome surfaces relay auth failures on icecandidateerror, which fires per server URL and carries the STUN error code verbatim β 401 with errorText: "Unauthorized" for a bad or expired credential, distinct from the 701 returned when the host is simply unreachable. Firefox does not raise the event with the same fidelity and reports the rejection in the about:webrtc ICE log instead. In a Chrome dump the same failure appears as an ICE candidate list with no relay entry at all, one of the patterns walked through in Reading chrome://webrtc-internals Dumps.
// Distinguish an auth rejection from an unreachable relay, per server URL
pc.addEventListener('icecandidateerror', (e) => {
if (e.errorCode === 401) { // HMAC mismatch or expired timestamp
console.warn('TURN rejected credential', e.url, e.errorText);
refreshRelayAuth(pc); // re-mint, then restart gathering
} else if (e.errorCode >= 700) { // 701: server unreachable, not an auth problem
console.warn('TURN unreachable', e.url, e.errorText);
}
});
// Poll getStats at 1 s: zero local candidates of type "relay" means no allocation succeeded
setInterval(async () => {
const stats = await pc.getStats();
let relayed = 0;
stats.forEach((s) => { if (s.type === 'local-candidate' && s.candidateType === 'relay') relayed++; });
if (relayed === 0) console.warn('no relay candidate β Allocate never completed');
}, 1000);
Common Implementation Mistakes
- Signing with hex output. coturn expects the base64 HMAC-SHA1;
.digest('hex')produces a string the relay will never match. - Putting the secret in the client. Any browser-side signing exposes
static-auth-secretand hands attackers an unlimited credential factory. Sign only on the backend. - Using a relative or millisecond timestamp. The expiry must be an absolute UNIX time in seconds;
Date.now()(milliseconds) or a TTL alone makes coturn read the username as already expired. - Signing
userIdinstead of the fullexpiry:userId. The HMAC must cover the entire username string the client will present, timestamp included. - Caching the credential response in a proxy. Without
Cache-Control: no-store, a shared cache can serve one userβs credential to another. Always mark it non-cacheable. - Passing the credential through a URL query string. Base64 output contains
+,/and=; a+decodes to a space in form-encoded query parsing, so the relay receives a corrupted password and returns401that looks identical to secret drift. Deliver credentials in a JSON body, or percent-encode them explicitly. - Trusting
user-quotato cap relay abuse. Each mint carries a fresh username, so per-username quotas reset every time a client re-fetches. Enforce the ceiling at the minting endpoint instead. - Minting a new credential per peer connection. In a multi-party call this issues one username per remote peer, fragments your relay logs across identities that are really one session, and defeats client-side caching for no security gain.
FAQ
What TTL should I choose? Between 1 and 12 hours for most apps. Shorter TTLs reduce the value of a leaked credential but force re-fetches on long calls and ICE restarts; cache the credential client-side for its lifetime and carry that cache across socket drops as described in Reconnecting Signaling Sockets Without Losing Session State, so a reconnect never blocks on a fresh mint.
Does the userId in the username need to match a real account?
No. coturn does not validate the user portion against any store β it only checks the HMAC and the expiry. Use userId for log correlation and rate-limiting on your own backend, not as an authorization gate.
Can I rotate the secret without breaking outstanding credentials?
Yes β coturn accepts multiple static-auth-secret lines. Add the new secret, reload, and let existing credentials expire by their TTL before removing the old one; both validate during the overlap window.
Why HMAC-SHA1 and not SHA-256? Because the digest is consumed as a long-term-credential password, and the key derivation around it is fixed by the STUN mechanism coturn implements. Substituting SHA-256 in your signer produces a string coturn will never reproduce, no matter how correct the rest of the flow is. If you need modern primitives end to end, the answer is TURN third-party authorization with signed tokens, not a stronger hash in the REST scheme β and it costs you the stateless property that makes this design attractive.
Can I bind a credential to one room, one peer, or one IP address?
Not through the credential itself. The username is the only field that travels, coturn parses nothing past the timestamp, and there is no place to assert scope. Encode a room identifier into the userId half if you want log correlation, but enforce the actual restriction elsewhere: allowed-peer-ip/denied-peer-ip on the relay to limit which destinations may be reached, and per-account mint limits on your backend to bound how many concurrent sessions one principal can open.
Related: this builds on TURN Server Configuration & Auth and pairs with Configuring Coturn for Production TURN Relay and STUN Server Deployment Strategies.