Authorizing private channels so tenants can't read each other
Channel naming plus a token endpoint that re-checks membership every time. The two mistakes that leak across tenants, and the closed capability set that limits the damage.
Multi-tenant realtime comes down to one rule: the only thing standing between tenant A and tenant B’s channel is the token your backend decided to mint. Infrai enforces the scope you ask for — POST /v1/realtime/token/issue binds a token to named channels and a closed set of capabilities — but it cannot know whether this user belongs to that tenant. That check is yours, and it has to run on every mint.
Two mistakes cause every cross-tenant leak I’ve seen described. Both are avoidable in about ten lines.
Mistake one: an account-scoped token
channels is optional. Omit it and you get a token that can attach to anything.
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": ["tenant:t_northwind:orders"],
"capabilities": ["subscribe"],
"ttl_seconds": 900
}'
{
"ok": true,
"data": {
"token": "rtt_9wQ1zV6pLkS3dHyBnMfE",
"jti": "jti_4kQ9mVzR1sXbNt",
"channels": ["tenant:t_northwind:orders"],
"capabilities": ["subscribe"],
"expires_at": "2026-09-21T03:27:11Z"
}
}
Always name the channels. A token with an explicit list is a token whose blast radius you can describe in a security review; one without is a token whose limits are “whatever channel names our attacker can guess”, and channel names are rarely secret — tenant:acme:orders is a name anyone can try.
Mistake two: trusting a claim from the client
The other leak is subtler. The browser asks for a token for tenant:t_acme:orders and your endpoint mints what was asked for, because the request came from a logged-in user.
Logged in is not the same as a member of that tenant.
import os
from fastapi import FastAPI, Header, HTTPException
import httpx
API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]
client = httpx.Client(base_url=API, headers={"Authorization": f"Bearer {KEY}"},
timeout=httpx.Timeout(3.0, connect=1.0))
app = FastAPI()
def tenant_of_user(user_id: str) -> str | None:
"""The user's tenant comes from the user record, never from the request body."""
resp = client.get(f"/v1/auth/user/get/{user_id}")
if resp.status_code == 404:
return None
resp.raise_for_status()
return (resp.json()["data"].get("metadata") or {}).get("tenant_id")
@app.post("/realtime/token")
def mint(channel: str, x_session_id: str = Header(...)) -> dict:
session = client.get(f"/v1/auth/session/verify/{x_session_id}")
if session.status_code == 404:
raise HTTPException(status_code=401, detail="no session")
session.raise_for_status()
user_id = session.json()["data"]["user_id"]
tenant = tenant_of_user(user_id)
# The channel the client asked for must belong to the tenant the USER RECORD
# says they're in. Deriving the prefix from the request is the whole bug.
if not tenant or not channel.startswith(f"tenant:{tenant}:"):
raise HTTPException(status_code=403, detail="channel not in your tenant")
issued = client.post("/v1/realtime/token/issue", json={
"client_id": user_id,
"channels": [channel],
"capabilities": ["subscribe"],
"ttl_seconds": 900,
})
issued.raise_for_status()
data = issued.json()["data"]
return {"token": data["token"], "expires_at": data["expires_at"], "jti": data["jti"]}
Every mint re-checks. That’s what makes a short TTL a security property rather than an inconvenience: membership revoked at 10:00 stops producing tokens at 10:00, and the last one expires fifteen minutes later.
Name channels so the check is a prefix test
A naming scheme that encodes the tenant makes authorisation a string comparison instead of a lookup table:
tenant:<tenant_id>:<resource> tenant:t_northwind:orders
tenant:<tenant_id>:user:<user_id> tenant:t_northwind:user:au_usr_lMmXG…
ops:<internal> ops:overview (staff only)
The prefix is the authorisation boundary. Anything that doesn’t start with the caller’s tenant prefix is refused without a database query, and a staff-only channel lives under a prefix no tenant token can ever match.
Grant the narrowest capability
| Capability | Who should have it | Risk if over-granted |
|---|---|---|
subscribe | any member of the tenant | none beyond the channel’s contents |
presence | members, when the roster is a feature | leaks who else is online |
history | members, if you expose past events | widens what a leaked token reads |
publish | almost nobody | a client can post as if it were your server |
publish in a browser is the one to refuse by default. If users need to send something, route it through your API — your server validates, then publishes — and the client keeps subscribe only.
Verify the boundary rather than assuming it
curl -sS "https://api.infrai.cc/v1/realtime/channel/list" \
-H "Authorization: Bearer ${INFRAI_API_KEY}"
{
"ok": true,
"data": {
"channels": [
{"channel_id": "chn_8320a6cf09f3426885646a", "name": "workspace:clinic-east", "type": "presence",
"vendor": "tencent_im", "created_at": "2026-08-25T19:21:29Z", "member_count": 0, "last_published_at": null}
],
"next_cursor": null
}
}
Read that list in a test and assert every name matches your scheme. A channel called orders with no tenant prefix is a channel somebody created by hand, and it’s the one that will be subscribed to by the wrong person.
Limitations
The platform has no concept of your tenants, so there’s no server-side rule you can install to say “tokens for this prefix require membership”. The check exists only in your mint endpoint, which means a second code path that issues tokens — an admin tool, a script, a forgotten internal service — bypasses it entirely. Keep minting in exactly one place and treat that as the security boundary it is.
There’s also no token introspection, so you can’t audit what’s currently valid; store the jti per session yourself if you want to revoke with POST /v1/realtime/token/revoke. Ably’s capability tokens carry richer per-channel rules and its client SDK handles refresh for you, so if fine-grained realtime authorisation is a core requirement it’s worth pricing.
What’s already here is the identity half: the session verify and the user record that decide membership are the same key as the token issue and the fan-out, so there’s no second vendor, no user-directory sync, and one GET /v1/account/usage covering all of it. Token and channel management report billing_class: free; publishing is billed per call at a live rate in GET /v1/discovery/realtime.publish (verified 2026-09-21), and those rates drift downward as vendor contracts improve.