Short-lived realtime tokens, and revoking one immediately
Scope a token to channels and capability bits, keep the TTL short, and use the jti to cut a client off before it expires.
Your API key must never reach a browser, so Infrai’s realtime clients connect with a token your backend mints: POST /v1/realtime/token/issue takes a client_id, the channels the token may attach to, the capabilities it carries and a ttl_seconds. When you need a client gone before that TTL runs out, POST /v1/realtime/token/revoke takes the token or its jti.
Three decisions define how safe this is: which capability bits you grant, how long the token lives, and whether you kept the jti.
Mint the narrowest token that works
curl -sS -X POST "https://api.infrai.cc/v1/realtime/token/issue" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{
"client_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
"channels": ["workspace:clinic-east"],
"capabilities": ["subscribe", "presence"],
"ttl_seconds": 900
}'
{
"ok": true,
"data": {
"token": "rtt_9wQ1zV6pLkS3dHyBnMfE",
"jti": "jti_4kQ9mVzR1sXbNt",
"channels": ["workspace:clinic-east"],
"capabilities": ["subscribe", "presence"],
"expires_at": "2026-09-21T03:22:11Z"
}
}
The capability set is closed: subscribe to receive, publish to send, presence to join the roster and see join/leave, history to read past events. Default is subscribe.
Grant publish to almost nobody. A browser client that can publish can post anything to that channel as if it came from your server, and every other client will render it — so if you’re building a chat, the message still goes through your API and your server publishes it, rather than the sender publishing directly.
channels narrows the token to specific names. Omitting it produces an account-scoped token, which is convenient and is also the thing you’ll regret: a token that can attach anywhere is a token that leaks across tenants the moment a channel name is guessable.
Store the jti with the session
The jti is the handle for revocation. If you don’t keep it, your only lever is waiting for expiry.
import os
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"})
# In production this is a table or a cache with a TTL, not a dict: a process
# restart that forgets its jtis is a process that can no longer revoke.
ISSUED: dict[str, list[str]] = {}
def issue(client_id: str, channels: list[str], capabilities: list[str] | None = None,
ttl_seconds: int = 900) -> dict:
resp = SESSION.post(
f"{API}/v1/realtime/token/issue",
json={"client_id": client_id, "channels": channels,
"capabilities": capabilities or ["subscribe"], "ttl_seconds": ttl_seconds},
timeout=15,
)
body = resp.json()
if not body.get("ok"):
raise RuntimeError(body["error"]["code"])
data = body["data"]
ISSUED.setdefault(client_id, []).append(data["jti"])
return data
def revoke_all_for(client_id: str) -> int:
"""Cut a client off now. Revoking by jti means you never have to store the
token itself, which is the half you don't want in your database."""
revoked = 0
for jti in ISSUED.get(client_id, []):
resp = SESSION.post(f"{API}/v1/realtime/token/revoke",
json={"token_or_jti": jti}, timeout=15)
if resp.ok and resp.json().get("data", {}).get("revoked"):
revoked += 1
ISSUED[client_id] = []
return revoked
if __name__ == "__main__":
token = issue("au_usr_lMmXGySJeGVM1xicqHGJaJbB", ["workspace:clinic-east"])
print(f"issued {token['jti']} expiring {token['expires_at']}")
print(f"revoked {revoke_all_for('au_usr_lMmXGySJeGVM1xicqHGJaJbB')} token(s)")
Note that revoke accepts either the token or the jti. Store the jti — it’s not a credential, so keeping it is safe, and it’s all you need.
Revoking a token doesn’t drop the connection
This is the distinction that catches people. A revoked token can’t be used to attach; a client already connected may hold that connection.
For “get this person out now”, pair the two calls:
curl -sS -X POST "https://api.infrai.cc/v1/realtime/token/revoke" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"token_or_jti": "jti_4kQ9mVzR1sXbNt"}'
curl -sS -X POST "https://api.infrai.cc/v1/realtime/user/disconnect" \
-H "Authorization: Bearer ${INFRAI_API_KEY}" \
-H "Content-Type: application/json" \
-d '{"client_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB", "channel": "workspace:clinic-east"}'
Revoke first, then disconnect. In the other order, the client can reconnect with a token that’s still valid and you’ve achieved nothing.
Choosing a TTL
| Client | TTL | Why |
|---|---|---|
| Web tab, refreshes on its own | 900s | short window if it leaks; refresh is cheap |
| Mobile app, backgrounds often | 3600s | fewer wake-ups to re-mint |
| Kiosk or display, always on | 3600s + scheduled re-mint | long-lived connections still need fresh tokens |
| Support agent with broad access | as short as tolerable | the highest-value token to leak |
Shorter is better, bounded by how annoying re-minting is. A token endpoint on your own API that re-issues on demand makes 900 seconds painless, and that endpoint should re-check authorisation every time rather than trusting that the client had a token before.
Limitations
There’s no introspection endpoint: you can’t ask whether a token is still valid, or list live tokens for a client. That’s why storing the jti yourself isn’t optional — the platform will revoke on demand, but it won’t tell you what you issued.
ttl_seconds is clamped to a server-side maximum, so a very long-lived token isn’t available by design, and a value at or below zero is refused with a 400 rather than defaulting to something generous. Ably and Pusher both ship richer client-side auth flows, including automatic token refresh handled inside their SDK, and if you’d rather not write the re-mint logic yourself that’s a genuine reason to pick one of them.
What you don’t have to add is a second vendor for the rest of the feature. The authorisation check in front of the mint can read GET /v1/auth/session/verify/{session_id} on the same key, an abuse signal goes to POST /v1/errors/capture, and the fan-out itself is POST /v1/realtime/publish — one credential, one bill, one GET /v1/account/usage covering all of it.
Token issue and revoke report billing_class: free in discovery; publishing is the billable part, live in GET /v1/discovery/realtime.publish (verified 2026-09-21). Platform rates drift downward as vendor contracts improve, so read them there.