Skip to main content

How to Rotate TURN Credentials

Introduction

TURN credentials have to be sent to the client side, so a credential that never changes is a credential that leaks eventually. Rotating credentials on a schedule limits how long a leaked credential is useful, without you having to detect the leak first.

This guide describes the rotation scheme we recommend for production applications: create credentials that live for 48 hours, and issue a fresh one every 24 hours so that the old and the new credential overlap.

If you have not yet read How to Create Expiring TURN Credentials, start there. This guide builds on it.

The same cycle works across one credential or a pool of them. To contain a leak to part of your traffic, or to break usage down by customer or environment, see Rotating a pool of credentials further down.

Two constraints that shape the design

Rotation has to work around two properties of TURN credentials. Both of them rule out the obvious approach of "create a short lived credential right before the call".

1. A new credential takes up to 2 minutes to become usable

A credential is not usable the moment the Create TURN Credential API returns. It has to propagate across Metered's global TURN network, which can take up to 2 minutes. Until it has propagated, a client using it can be rejected when it tries to allocate a relay, and no relay candidate is gathered.

This is almost always mistaken for a bug in the application's WebRTC configuration, because the exact same credential works a short while later.

Consequence: never create a credential at call-setup time. Create it well before anyone needs it.

2. The expiry is a hard cutoff

When expiryInSeconds elapses, the credential stops working immediately. It does not finish serving calls that are already in progress. A call that is relayed through the TURN server when its credential expires is disrupted.

Consequence: the lifetime has to comfortably exceed your longest expected call, plus the time the credential may sit cached on a device that has not refreshed it yet.

Create each credential with expiryInSeconds: 172800 (48 hours), and issue a new one every 24 hours. Because you rotate at half the lifetime, the credentials overlap:

TimeActionValid credentials
Day 0Create credential A, valid until Day 2A
Day 1Create credential B, valid until Day 3. Hand B to clientsA, B
Day 2A expires on its own. Every client has had 24 hours to pick up BB
Day 3Create credential C, valid until Day 5. Hand C to clientsB, C

Three useful properties fall out of the overlap:

  1. Any credential a client holds always has at least 24 hours of validity left, so it can never expire in the middle of a call.
  2. Propagation happens off the critical path. Each credential is created a full day before anything depends on it, so the 2 minute propagation window is never visible to a user.
  3. A client that misses a refresh still works. A device that was offline or backgrounded during the rotation is still holding a valid credential.
One credential can serve many calls

There is no need to create a credential per call, per user, or per pair of users. One credential can back any amount of traffic. To separate traffic by environment or customer, use a small pool as described in Rotating a pool of credentials, not one credential per session.

Step 1: Create the credential from your back-end

Never call this API from the front-end

The Create TURN Credential API is authenticated with your Secret Key, which is account-scoped. It must only ever be used from your server. The front-end only ever receives the credential-scoped apiKey, or the ICE servers array itself.

Call Create TURN Credential with a 48 hour expiry and a label:

const METERED_DOMAIN = "https://<appname>.metered.live";
const SECRET_KEY = process.env.METERED_SECRET_KEY;

async function createTurnCredential() {
const response = await fetch(
`${METERED_DOMAIN}/api/v1/turn/credential?secretKey=${SECRET_KEY}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
expiryInSeconds: 172800, // 48 hours
label: "rotating",
}),
}
);

if (!response.ok) {
throw new Error(`Failed to create TURN credential: ${response.status}`);
}

return response.json();
}

The response contains the credential and its apiKey:

{
"username": "5e7dbfbe19c6c158515907a6",
"password": "wQX5Ze0EExayWJk9",
"expiryInSeconds": 172800,
"label": "rotating",
"apiKey": "56c193debb416385ade8d9a77e277ea33c0f"
}

Store the apiKey (and the creation time) somewhere your application server can read it. This is the value your clients will use.

Step 2: Run the rotation on a 24 hour schedule

Rotate from a scheduled job, not from a request handler. Any scheduler works: a cron job, a scheduled cloud function, or an interval in a long-running process.

const ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours

async function rotateTurnCredential() {
const credential = await createTurnCredential();

// Persist it wherever your application server reads it from.
// The previous credential is intentionally left alone: it stays valid
// for another 24 hours so clients that have not refreshed keep working.
await saveCurrentTurnCredential({
apiKey: credential.apiKey,
username: credential.username,
createdAt: Date.now(),
});

console.log("Rotated TURN credential:", credential.username);
}

// On boot, make sure there is a current credential, then rotate daily.
await rotateTurnCredential();
setInterval(rotateTurnCredential, ROTATION_INTERVAL_MS);
Do not delete the outgoing credential at rotation time

The whole point of the overlap is that the previous credential stays valid while clients pick up the new one. Let it expire on its own, or delete it only after the overlap window has passed. Deleting it at rotation time breaks every client that has not refreshed yet.

Step 3: Serve the credential to your clients

Expose an endpoint on your own server that returns the current apiKey. Clients then call Get TURN Credential with that apiKey to fetch the ICE servers array. The apiKey is credential-scoped and safe to use in the front-end.

// Your back-end
app.get("/api/turn-credential", requireYourOwnAuth, async (req, res) => {
const credential = await getCurrentTurnCredential();
res.json({ apiKey: credential.apiKey });
});
// Your front-end
const { apiKey } = await fetch("/api/turn-credential").then((r) => r.json());

const iceServers = await fetch(
`https://<appname>.metered.live/api/v1/turn/credentials?apiKey=${apiKey}`
).then((r) => r.json());

const peerConnection = new RTCPeerConnection({ iceServers });

Have clients fetch a fresh apiKey when the application starts, when the user logs in, or when the app returns to the foreground. Do not fetch it in the moments before a call starts and assume it is new: that is fine with this scheme, because the credential it returns was created at least 24 hours ago and has long since propagated.

Use the full ICE servers array

Use the whole array returned by the Get TURN Credential API rather than hardcoding a single turn: URL. It includes UDP and TCP on port 80 and UDP and TLS on port 443, which matters for clients behind restrictive firewalls.

Step 4: Clean up old credentials

Expired credentials stop working on their own, so cleanup is optional. If you want to keep the list tidy, list the credentials for your label and delete the ones that are past the overlap window.

List them with Get TURN Credentials:

curl "https://<appname>.metered.live/api/v2/turn/credentials?secretKey=<YOUR_SECRET_KEY>&label=rotating"

Delete every active credential carrying a label with Delete TURN Credential by Label:

curl -X DELETE "https://<appname>.metered.live/api/v2/turn/credential/by_label?secretKey=<YOUR_SECRET_KEY>&label=rotating"
Deleting by label deletes all of them

Delete TURN Credential by Label removes every active credential sharing that label, including the one currently in use. To retire a single credential, use Delete TURN Credential with its username, or Disable TURN Credential if you want to keep it and turn it off.

Responding to a leaked credential

Scheduled rotation limits exposure, it does not replace incident response. If you know a specific credential has leaked:

  1. Disable or delete that credential immediately by username.
  2. Create a replacement and push it to your clients.
  3. Remember that the replacement needs up to 2 minutes to propagate, so expect a short window where new calls cannot allocate a relay.

Choosing different numbers

The 48 hour / 24 hour pair is a good default. If you change it, keep the two rules that make the scheme safe:

  • Rotate at no more than half the lifetime. This is what guarantees every client always holds a credential with at least one full rotation period of validity remaining.
  • Keep the lifetime well above your longest expected call. The expiry is a hard cutoff, so the lifetime is a ceiling on call duration for any client that picked the credential up just before it expired.

For example, a 12 hour lifetime rotated every 6 hours is fine for an app whose calls last minutes. A 2 hour lifetime rotated every hour is not a good idea if a call can run 90 minutes.

Rotating a pool of credentials

The cycle above is the same whether you run it across one credential or several. How many credentials you run it across is a separate decision, driven by what you need to keep apart.

Run a pool, with each slot on its own cycle, when you want to:

  • Contain a leak. Disabling the one credential everybody holds logs out every client. With a pool you disable one slot, and only the clients assigned to it are affected.
  • Attribute usage. Each credential's bytes are queryable on its own, so giving a slot to each environment, region, or major customer produces a usage breakdown without any extra reporting on your side.
  • Spread out credential creation. Rotating one slot at a time means you create a single credential per interval rather than replacing everything at once.

A pool costs you a slot table and per-slot bookkeeping, so weigh it against those three. With nothing to keep apart, one credential carries the same traffic.

Sizing the pool

Give a slot to each thing you actually want to separate, and no more. The number falls out of that decision, so there is no pool size to pick for its own sake. A handful of stable cohorts, such as staging and production, or a few named customers, is the case this pattern suits.

Do not give every tenant its own slot when tenants are numerous or churn. That grows without bound, and the segmentation it buys is what Projects already does properly.

Your plan limits how many credentials can be active at once

Every plan caps the number of credentials that can exist at the same time. Once you reach the cap, credential creation is rejected. The project endpoint documents this as a 403 with the message Maximum credential limit reached, and the same limit applies to your account as a whole.

Treat that response as a signal to delete credentials you no longer need, or to use a smaller pool. Do not retry the call in a loop.

On Business and Enterprise plans, consider Projects instead

TURN Projects give each tenant its own API key, quota, usage tracking, and webhooks, with no slot table for you to maintain. Reach for a credential pool when Projects is not available on your plan, or when you want to segment inside a project.

How the pool rotates

Each slot runs exactly the cycle described above, on its own: a 48 hour lifetime, replaced every 24 hours. The only thing the pool adds is a phase offset, so the slots do not all expire together. Rotate one slot every rotation period divided by pool size.

Give each slot a stable label so you can find its current credential later. This is the helper from Step 1, taking the label as an argument.

const POOL_SIZE = 4;                                  // one slot per cohort you separate
const LIFETIME_SECONDS = 172800; // 48 hours, same as before
const ROTATION_MS = 24 * 60 * 60 * 1000; // each slot is replaced every 24 hours
const SLOT_INTERVAL_MS = ROTATION_MS / POOL_SIZE; // stagger the slots

async function createTurnCredential(label) {
const response = await fetch(
`${METERED_DOMAIN}/api/v1/turn/credential?secretKey=${SECRET_KEY}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ expiryInSeconds: LIFETIME_SECONDS, label }),
}
);

if (!response.ok) {
// A 403 here means the credential cap for your plan is reached.
throw new Error(`Failed to create TURN credential: ${response.status}`);
}

return response.json();
}

async function rotateSlot(slot) {
const credential = await createTurnCredential(`pool-${slot}`);

// Store it as the current credential for this slot. As with a single credential,
// the one it replaces is left alone and expires 24 hours from now.
await saveSlotCredential(slot, credential);

console.log(`Rotated pool slot ${slot}:`, credential.username);
}

// Round-robin: one slot per interval, so creations never bunch up.
let nextSlot = 0;
setInterval(async () => {
await rotateSlot(nextSlot);
nextSlot = (nextSlot + 1) % POOL_SIZE;
}, SLOT_INTERVAL_MS);

On first boot, rotate every slot once so the pool is populated, then let the interval take over.

Assigning clients to a slot

Map each client to a slot with a stable hash of an identifier you already have. Stable matters: a client that keeps landing on the same slot is what makes the per-slot usage figures mean anything.

function slotForClient(id) {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) | 0;
}
return Math.abs(hash) % POOL_SIZE;
}

app.get("/api/turn-credential", requireYourOwnAuth, async (req, res) => {
const slot = slotForClient(req.user.tenantId);
const credential = await getSlotCredential(slot);
res.json({ apiKey: credential.apiKey });
});

If you are separating fixed cohorts rather than hashing, map the cohort to a slot directly and skip the hash.

Reading usage per slot

Each slot's credential is metered on its own. Pass its username to Get Current Usage For User:

curl "https://<appname>.metered.live/api/v1/turn/current_usage_for_user?secretKey=<YOUR_SECRET_KEY>&username=<SLOT_USERNAME>"

During the overlap window a slot has two live credentials, the outgoing one and its replacement. Add both figures to get that slot's total for the period.

Retiring a slot

Do not retire a slot with delete-by-label

A slot has two live credentials during its overlap window, and Delete TURN Credential by Label removes every active credential carrying the label. Called on a slot label it deletes the replacement along with the credential you meant to retire, cutting off clients that had already moved across.

Retire one credential at a time by username with Delete TURN Credential, or Disable TURN Credential to turn it off while keeping it. Delete-by-label is the right call only when you want the whole slot gone.

To respond to a leak in a pool, disable the affected slot's username, rotate that slot immediately, and have its clients re-fetch. Every other slot is untouched.

Anti-patterns

Don'tWhyDo this instead
Create a credential when the user clicks "Join call"It has not propagated yet, so the allocation can be rejectedCreate it in advance and reuse it
Create a credential per call or per userNo benefit, and every one of them has the propagation delayOne rotating credential can serve all traffic
Use a short expiry, such as the length of one callThe credential dies mid-call for anyone who runs longLong lifetime plus scheduled rotation
Delete the old credential the moment you create the new oneBreaks clients that have not refreshedLet it expire, or delete after the overlap
Call the Create TURN Credential API from the front-endExposes your account-scoped Secret KeyCall it from your back-end, hand the client the apiKey
Give every tenant its own pool slotGrows without bound and runs into your plan's credential capA slot per stable cohort, or Projects for real multi-tenancy
Retire a pool slot with delete-by-labelIt removes the replacement too, cutting off clients that already moved acrossDelete or disable one username at a time

Summary

Create TURN credentials with a 48 hour expiry, issue a new one every 24 hours, hand clients the credential-scoped apiKey, and let the old credential expire by itself. The overlap keeps credentials from expiring during a call, and it keeps the up-to-2-minute propagation delay off the path of anything a user is waiting for.

If you need to contain a leak to part of your traffic, or to see usage broken down by customer or environment, run the same cycle across a small pool of credentials with one slot per cohort, staggered so they do not all expire together.