Designing RTC token TTL and permissions for a leaked token

Assume the token leaks and ask what it buys. A room-scoped, short-lived, publish-only-if-needed token limits the answer to almost nothing.

Design the token for the case where it leaks, because it will — into a browser console, a support screenshot, a client-side error report, a log line nobody meant to keep. Infrai’s POST /v1/rtc/token/issue gives you three levers that bound the damage: the token is scoped to one room, it carries an identity, and ttl_s decides how long it’s worth anything.

Set those three well and a leaked token buys someone a few minutes in one specific call as a named identity. Set them badly and it buys a day of admin access.

The three levers

curl -sS -X POST "https://api.infrai.cc/v1/rtc/token/issue" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "room": "support-4821",
    "identity": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
    "display_name": "Ada L.",
    "ttl_s": 300,
    "can_publish": true,
    "can_subscribe": true,
    "can_publish_data": false,
    "is_admin": false
  }'
{
  "ok": true,
  "data": {
    "token": "rtct_9wQ1zV6pLkS3dHyBnMfE",
    "room": "support-4821",
    "identity": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
    "vendor": "tencent_rtc",
    "expires_at": "2026-09-21T03:30:19Z",
    "ws_url": "wss://rtc.example-region.tencent-rtc.com"
  }
}

Room scope is built in — the token names its room and can’t be used for another. Identity is in the token, so a leaked token joins as that person rather than anonymously, which means your participant list shows a duplicate identity and your own logs have something to correlate.

ttl_s: 300 is the lever most worth tightening. A token authorises the join, not the call, so a five-minute TTL still supports a two-hour meeting — and the leaked copy is worthless five minutes after it was minted.

What each flag costs you if it leaks

FlagIf the token leaks
can_subscribe onlythe holder can listen to that one call
+ can_publishthey can also speak and appear on camera
+ can_publish_datathey can inject data messages your app may trust
+ is_adminthey can act on the room itself

can_publish_data deserves more suspicion than it gets. If your client treats data messages as application events — “the agent shared a file”, “the call is being recorded” — then a token with that flag can forge them, and your UI will believe it. Grant it only where you need it, and validate data messages in the client as if they came from a stranger, because they might have.

is_admin in a browser token is the one to refuse outright. Moderator actions belong on your backend, which already holds the API key and can call POST /v1/rtc/participant/kick/{room} itself.

A role table, not per-call-site flags

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

// One table, four roles, no call site inventing its own flags. The TTLs differ
// on purpose: a viewer's token is worth less if it leaks, so it can live longer
// without costing you anything.
const ROLES = {
  viewer:      { ttl_s: 900, can_publish: false, can_subscribe: true,  can_publish_data: false, is_admin: false },
  participant: { ttl_s: 300, can_publish: true,  can_subscribe: true,  can_publish_data: false, is_admin: false },
  collaborator:{ ttl_s: 300, can_publish: true,  can_subscribe: true,  can_publish_data: true,  is_admin: false },
  // Deliberately absent: a browser-facing "moderator" role. Moderation runs
  // server-side with the API key, never with an is_admin token in a client.
};

export async function mintFor(role, room, identity, displayName) {
  const spec = ROLES[role];
  if (!spec) throw new Error(`unknown role: ${role}`);

  const res = await fetch(`${API}/v1/rtc/token/issue`, {
    method: "POST",
    headers: { authorization: `Bearer ${KEY}`, "content-type": "application/json" },
    body: JSON.stringify({ room, identity, display_name: displayName, ...spec }),
  });
  const body = await res.json();
  if (!body.ok) throw new Error(body.error?.code ?? "mint_failed");
  return { token: body.data.token, wsUrl: body.data.ws_url, expiresAt: body.data.expires_at };
}

The absence of a browser moderator role is the design decision, and writing it as a comment in the table is how it survives the next person who needs a moderator button.

There is no revoke, so expiry is your revoke

Worth stating plainly: this API has no token revocation. You cannot invalidate an issued RTC token — you can kick the participant from the room with POST /v1/rtc/participant/kick/{room}, but the token itself keeps working until expires_at.

That’s the limitation that makes TTL the whole security model:

import os
import time

import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
SESSION = requests.Session()
SESSION.headers.update({"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"})

# Blocked (room, identity) pairs. The mint endpoint is the only place a removal
# can be enforced, because an issued token cannot be withdrawn.
BLOCKED: set[tuple[str, str]] = set()
LAST_MINT: dict[tuple[str, str], float] = {}
MINT_FLOOR_SECONDS = 20


def mint(room: str, identity: str, display_name: str, ttl_s: int = 300) -> dict:
    key = (room, identity)
    if key in BLOCKED:
        raise PermissionError("removed from this room")
    # A client reconnect loop that re-mints without a floor turns a flaky network
    # into a billing line, since token issue is a billed call.
    last = LAST_MINT.get(key, 0.0)
    if time.monotonic() - last < MINT_FLOOR_SECONDS:
        raise RuntimeError("minting too frequently; reuse the current token")

    resp = SESSION.post(
        f"{API}/v1/rtc/token/issue",
        json={"room": room, "identity": identity, "display_name": display_name,
              "ttl_s": ttl_s, "can_publish": True, "can_subscribe": True,
              "can_publish_data": False, "is_admin": False},
        timeout=25,
    )
    resp.raise_for_status()
    LAST_MINT[key] = time.monotonic()
    return resp.json()["data"]


def remove(room: str, identity: str) -> bool:
    BLOCKED.add((room, identity))
    kicked = SESSION.post(f"{API}/v1/rtc/participant/kick/{room}",
                          json={"room": room, "identity": identity}, timeout=20)
    return bool(kicked.ok)


if __name__ == "__main__":
    print(mint("support-4821", os.environ["IDENTITY"], "Ada L."))

Block, then kick, and let the short TTL close the door behind them.

Pick the TTL from the reconnect experience

TTLLeak windowCost
120stwo minutesfrequent re-mints; needs a floor
300sfive minutesgood default for participants
900sfifteen minutesfine for viewers
3600s+an hour or moreonly for trusted internal use

Short TTLs are cheap because re-minting is one call through an endpoint that re-checks authorisation — which is a property you want anyway, since it means a permission revoked mid-meeting takes effect on the next reconnect.

Limitations

No revocation, no mid-call permission change, no scope narrower than a room. LiveKit’s server API can update a participant’s grants in place and mute tracks remotely, so if in-call permission management is a requirement rather than a nicety, it’s the better fit and that isn’t close. Agora similarly exposes more moderation surface.

What’s on the same credential is the authorisation that decides the mint: GET /v1/auth/session/verify/{session_id} and the user record behind it, plus POST /v1/errors/capture when a mint is refused and POST /v1/metrics/report for the rate you’ll want to watch — one key, one bill, one GET /v1/account/usage. Token issue is the billed call here (live figure in GET /v1/discovery/rtc.token.issue, verified 2026-09-21) while room and participant management report billing_class: free; rates drift downward as vendor contracts improve.

References

Browse more rtc developer guides