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.

Anatomy of a time-limited TURN credential The username splits into an absolute UNIX expiry and an informational userId. The full string is signed with HMAC-SHA1 over the static-auth-secret and base64-encoded to form the credential, which coturn recomputes and compares at Allocate time. Step 1 β€” compose the username string 1780000000 : alice absolute UNIX expiry, in seconds userId β€” log correlation only Step 2 β€” sign the whole string, then base64 the digest HMAC_SHA1(username, static-auth-secret) base64(digest) = credential base64, never hex β€” coturn compares the base64 form byte for byte Step 3 β€” the relay validates without storing anything coturn recomputes HMAC-SHA1 over the presented username at Allocate time reject if the expiry has passed, or if the digests differ β€” no database lookup
The two username fields, the signing step, and the stateless check coturn performs on every allocation.

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.

Mint, deliver, redeem: the ephemeral credential round-trip The browser requests a credential from the application backend, which signs an expiry and userId with the shared secret and returns username, credential and TTL. The browser caches it and presents it to coturn, which recomputes the HMAC and returns a relayed address. Browser App backend coturn relay GET /api/turn-credentials sign expiry:userId, HMAC-SHA1 username, credential, ttl 3600 cache for TTL, build iceServers Allocate β€” username plus credential recompute HMAC, check expiry Success β€” relayed address allocated Past the expiry the same pair returns 401 and the browser simply re-fetches β€” the relay stores nothing between calls.
One credential round-trip: the secret never leaves the backend and the relay keeps no per-user state.

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

  1. 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
  1. 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
  1. On success the log shows INFO: session <id>: realm <…> user <1780000000:alice>: incoming packet ALLOCATE processed, success followed by relayed address … allocated.
  2. On an expired or wrong credential the log shows 401 then error 401 (Unauthorized) β€” confirm the username prefix is a future UNIX timestamp and that the secret matches exactly. A 438 (Stale Nonce) is normal mid-handshake; the client retries with the fresh nonce automatically.
  3. A common mismatch is hex-vs-base64: if the credential validates with openssl but 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.

Diagnosing a rejected Allocate An ordered set of checks: stale nonce is benign, then verify the expiry prefix is a future UNIX second, then that the openssl digest matches the Node digest, then that the backend secret matches static-auth-secret, ending in a successful allocation. Log line reads 438 (Stale Nonce)? yes Benign β€” the client retries with the fresh nonce no Username prefix a future UNIX second? no Milliseconds or a bare TTL β€” send absolute seconds yes openssl digest matches the Node digest? no hex digest in use β€” switch to base64 output yes Backend key equals static-auth-secret? no Secret drift β€” resync the value and reload coturn yes ALLOCATE processed, success β€” relayed address returned
Order of elimination for a rejected allocation, from the benign 438 through to a genuine secret mismatch.

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

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.