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

CapabilityWho should have itRisk if over-granted
subscribeany member of the tenantnone beyond the channel’s contents
presencemembers, when the roster is a featureleaks who else is online
historymembers, if you expose past eventswidens what a leaked token reads
publishalmost nobodya 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.

References

Browse more realtime developer guides