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. 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 restartIce() does not trigger 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.
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.
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'.
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.
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 to avoid redundant signalling round-trips.
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.
Related: this builds on TURN Server Configuration & Auth and pairs with Configuring Coturn for Production TURN Relay and STUN Server Deployment Strategies.