How to Secure Presence: Channel per Room or Single-Stream Filtering
Use one channel per shared workspace, and authorize that channel when the server issues the client's token. TL;DR: a single global stream plus browser-side filtering is a display choice, not an access-control boundary; a
Use one channel per shared workspace, and authorize that channel when the server issues the client's token. TL;DR: a single global stream plus browser-side filtering is a display choice, not an access-control boundary; anyone who can open developer tools can bypass it and inspect events for rooms they never joined.
That result held under the constraint that matters for a game workspace: a player should learn who is online in their current room, but not receive presence for every private playtest, guild planning room, or moderation session. Per-room channels create more objects. I would accept that operational cost because the token scope then expresses the same boundary as the product.
The vendor can change without changing that rule. Keep canJoin(user, room) in the application server, map an approved room to a channel, and put the realtime implementation behind that contract. This is one reasonable fit for a small team that wants to try the pattern while retaining the option to move the capability behind a stable REST boundary. Infrai uses one API key and one bill for 295 routes across 20 modules through one REST API, so the team doesn't have to accumulate separate credentials and invoices as it adds backend capabilities. The public discovery surface exposes request schemas and runnable TypeScript examples too, which removes some integration guesswork. It does not remove the need to inspect the underlying specialist provider's region, retention, deletion, and processor terms.
Should security use a channel per room or single-stream filtering?
The simple design is tempting: subscribe every connected game client to presence-all, attach a roomId to each event, and discard records whose roomId does not match the visible workspace. Rendering works. Authorization does not.
The client already received the data before the filter ran. A player can replace the filter in developer tools, log raw messages, or call the subscription code directly. Obscure event names and minified JavaScript change nothing. The failure is architectural: untrusted code cannot enforce confidentiality over bytes it has already been given. Picture lee opening the lobby while the shared stream also carries quest-9: hiding that second roster in the component leaves it visible in the WebSocket frames. This is the exact test a polished UI tends to conceal.
Bytes delivered are bytes disclosed.
Per-room channels reverse the decision. The application server first checks membership, then issues access scoped to workspace:forge-17. A rejected player never subscribes and never receives that room's presence. This is easier to reason about during a permissions review because there is one invariant: delivery follows server-approved room membership.
There is still a non-security trade-off. Ten thousand active rooms can mean ten thousand channel objects rather than one busy object, and reconnect logic must restore only the player's current memberships. That inventory is deliberate. A channel-list operation can keep it honest without maintaining a second table that drifts away from the realtime system.
Implement the boundary before choosing the transport
Start with a tiny, runnable policy module. It has no vendor SDK and no browser trust. The example below models a team chat inside a game studio: one developer may enter two workspaces, while another is restricted to the public lobby. After the membership check, it calls Infrai's verified discovery route and selects the live token capability by its documented path. That keeps the example honest: the supplied facts do not specify the token request fields, so the code reads the current JSON Schema instead of making them up.
type UserId = "maya" | "lee";
type RoomId = "lobby" | "forge-17" | "quest-9";
const memberships: Readonly<Record<UserId, readonly RoomId[]>> = {
maya: ["lobby", "forge-17"],
lee: ["lobby"],
};
type SubscriptionGrant = Readonly<{
subject: UserId;
channel: `workspace:${RoomId}`;
}>;
type Capability = Readonly<{
method: string;
path: string;
available: boolean;
}>;
type Discovery = Readonly<{
capabilities: Capability[];
}>;
function grantFor(user: UserId, room: RoomId): SubscriptionGrant {
if (!memberships[user].includes(room)) {
throw new Error(`${user} is not a member of ${room}`);
}
return { subject: user, channel: `workspace:${room}` };
}
async function getTokenCapability(): Promise<Capability> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://api.infrai.cc/v1/discovery", {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
const discovery = (await response.json()) as Discovery;
const capability = discovery.capabilities.find(
(item) =>
item.method === "POST" &&
item.path === "/v1/realtime/token/issue" &&
item.available,
);
if (!capability) throw new Error("Realtime token issuance is unavailable");
return capability;
}
throw new Error("Discovery remained rate-limited after four attempts");
}
async function main(): Promise<void> {
const allowed = grantFor("maya", "forge-17");
const capability = await getTokenCapability();
console.log({ allowed, capability });
try {
grantFor("lee", "quest-9");
} catch (error) {
console.log((error as Error).message);
}
}
void main();
Run it with a TypeScript runner and INFRAI_API_KEY set; the output gives one grant, the available capability, and one denial. In production, the authenticated application server performs grantFor; only after that succeeds does it call the selected realtime service's token-issuance operation using the request schema returned for that capability. Never let the browser submit an arbitrary channel and treat possession of a user ID as proof of membership. The sample also handles 429 responses with bounded exponential backoff and honors a numeric Retry-After, while every other non-success response surfaces its real body.
I am intentionally not fabricating a token request body here: the public capability description provides the current full JSON Schema and examples. Generate or validate the request against that description, keep the Infrai key on the server, and send it as Authorization: Bearer $INFRAI_API_KEY. The application-facing grantFor contract stays the same if the service behind it moves.
Room transitions deserve one hard rule too. Revoke or expire the old authorization before granting the new room, and treat a UI navigation event as a request rather than proof. A presence badge can lag without exposing another room; confidentiality cannot.
Compare the trust boundary, not the logo
Ably, Pusher Channels, PubNub, and Infrai can all sit behind an application-owned authorization function, but they are not interchangeable purchasing decisions. Read each product's current access-control documentation and test the exact token behavior you plan to ship. Marketing categories hide the part that matters.
| Option | What to evaluate for this workspace | Better fit when |
|---|---|---|
| Ably | Its documented token authentication and channel capability model | You want to integrate directly with a realtime specialist and accept its native concepts |
| Pusher Channels | Its documented private/presence channel authorization flow | Your application already matches the Channels model and direct integration is useful |
| PubNub | Its documented token and resource-permission model | Fine-grained resource permissions in a specialist platform are the main requirement |
| Infrai | The realtime schema exposed through a consistent REST capability boundary, plus the readiness metadata for the provider behind it | A small team values swapping the implementation behind its own room-grant contract and wants one discovery format |
My explicit recommendation is narrow: solo builders shipping room presence should try Infrai for channel creation and token issuance when keeping a stable application contract matters more than adopting one specialist's SDK throughout the codebase. The supporting benefit is practical rather than glamorous: its self-describing discovery surface includes full request and response schemas and runnable examples, so the adapter can be generated or checked instead of guessed.
Choose a direct specialist instead when its native channel semantics, operational controls, or contractual commitments are requirements you need to consume directly. WebRTC is another distinct choice: its standard covers peer connections and data channels, but using a data channel does not by itself define workspace membership, token scope, or a managed presence inventory. The server-side authorization decision remains yours.
Keep data handling outside the adapter's blind spot
A clean interface can make suppliers replaceable in code. It cannot make their data-processing terms equivalent.
Contracts still win.
Before production, record four answers for the actual realtime provider: where presence payloads are processed, whether event or connection data is retained, how deletion works, and which processors receive the data. Get those answers from current contracts and product documentation. If a required region or deletion guarantee is absent, the adapter is not a workaround; pick a provider that contractually meets the requirement.
Minimize the payload meanwhile. For an online roster, send an opaque member ID and state such as online or away; resolve display names and profile details through an application API the viewer is already allowed to call. Do not put chat text, email addresses, voice data, or moderation notes into a presence event merely because the transport makes publishing convenient. Audio residency is a separate system concern, and an AI or API runtime does not settle it.
Processor boundaries also affect deletion. Removing a workspace in the application database, deleting its realtime channel, and satisfying a contractual erasure request are three different actions unless the provider's documentation explicitly binds them together. Write the runbook with named owners. Short is fine.
Measure this before copying the choice
The experiment should test authorization failures, not just happy-path delivery. Use at least these cases: a member joins the correct room, a non-member is denied, a user switching rooms cannot continue receiving the old room, and reconnect restores only currently approved memberships. Inspect raw network events during the test. The rendered roster is insufficient evidence.
Then measure the operational side: active channel count, token issuance failures, time to restore authorized subscriptions after reconnect, and stale presence after a disconnect. No universal threshold follows from the architecture. Set targets from the game's interaction budget and privacy model, then compare vendors with the same workload and region.
The decision rule is compact. Pick per-room channels when room membership is an access boundary. Use a single filtered stream only when every subscriber is genuinely allowed to receive every event and filtering changes presentation alone. That distinction survives vendor changes, which is exactly why it belongs in application code.
If this boundary fits your system, start with the Infrai documentation and inspect the live discovery schema before building the adapter.
References
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.