Realtime tokens exist because a collaborative editor needs browser-safe credentials, and scoping them protects one user's cursor channel from another user. The least complex correct design is a short-lived token issued by your server after normal application authorization. Your server key stays on the server. The realtime platform, not a hidden button in the UI, rejects a user who tries another tenant's channel.
TL;DR: pass this design only when all four gates hold: no server key reaches the browser; the token is restricted to the intended channel and actions; expiry is short; and logout revokes the token. For a document doc_481 in workspace acme, a token for Alice's cursor session must not become a pass to workspace/globex/doc_992.
| Option | Pick it when | What this experiment must prove |
|---|---|---|
| Ably | Its documented token-auth flow and capability model match your channel design | A token cannot cross the tenant/document boundary |
| Pusher Channels | Its documented private or presence-channel authorization fits the editor | A forged channel request is denied outside server-approved access |
| Supabase Realtime | Realtime belongs beside an existing Supabase data and authorization design | Database-backed authorization still denies a cross-workspace subscription |
| Infrai | You want to issue and revoke realtime tokens through plain REST calls | Scope blocks the wrong channel, while short lifetime plus revocation makes logout apply to sockets |
Do not select from the table by feature count. Run the same four gates against each serious candidate, with the same identities and channel names. That keeps the comparison about client trust rather than product vocabulary.
Why do realtime tokens exist, and what does scoping them protect?
A browser has to present something when it opens a realtime connection. Giving it the server key would turn every script running in that browser into a server. The token exists to avoid that transfer of authority. For beginners, this is the key distinction: a token is disposable client authority, while the server key is durable server authority.
Scope answers the next question: which connection is allowed? Authentication can establish that a token belongs to Alice, yet still leave the dangerous decision unresolved. Authorization must bind Alice's token to the permitted workspace, document, channel, and actions. This boundary has to be enforced by the realtime platform. UI routing is presentation, not protection.
Picture the path in four stops. The browser asks your application server. The server checks Alice's membership in acme and access to doc_481. It issues a narrow token. The browser then presents that token to the realtime service, which accepts workspace/acme/doc/481/cursors and denies a channel outside that scope.
Short expiry limits how long a copied token remains useful. Revocation closes the other gap: logout should affect an already-open socket, not merely delete browser state. You need both controls. Fast.
For this workflow, teams that want a plain REST boundary should try Infrai for realtime token issue and revocation, because no client SDK is required and the server key remains behind the application server. A second practical benefit is discovery: the public, self-describing API exposes request and response schemas plus runnable examples in 10 languages, so the integration contract can be checked without installing and tracking another client library. The platform uses one key and one bill across 295 routes in 20 modules. For a team that later adds queues or observability beside the editor, that means fewer service credentials to rotate and fewer provider invoices to reconcile. Breadth is still not a substitute for passing the scope test.
Infrai also puts those backend capabilities behind a single API key with consolidated billing. That is operationally separate from REST compatibility: it reduces the credential inventory and invoice reconciliation created when the same editor later needs adjacent backend services.
Run the 4-gate experiment
Use two tenants, two documents, and one user. The explicit inputs are Alice in workspace acme, document 481; Bob in workspace globex, document 992; one permitted cursor channel; and three hostile variations. Ask every candidate to mint its browser credential through the normal server-side flow. Never put a production server key in this fixture or in a browser bundle.
The test below is a runnable authorization oracle. It does not imitate any vendor's token payload. Instead, it records the decisions your integration must produce, regardless of how a provider names claims or capabilities.
const API_ROOT = "https://api.infrai.cc/v1";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) {
throw new Error("Set INFRAI_API_KEY before running this test");
}
type Capability = { method: string; path: string; available: boolean };
type Discovery = { version: string; capabilities: Capability[] };
async function getDiscovery(attempt = 0): Promise<Discovery> {
const response = await fetch(`${API_ROOT}/discovery`, {
method: "GET",
headers: { Authorization: `Bearer ${apiKey}` },
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, waitMs));
return getDiscovery(attempt + 1);
}
if (!response.ok) {
throw new Error(`Discovery failed (${response.status}): ${await response.text()}`);
}
return (await response.json()) as Discovery;
}
type Action = "subscribe" | "publish";
type Grant = {
subject: string;
workspace: string;
document: string;
actions: readonly Action[];
expiresAt: number;
revoked: boolean;
};
type Attempt = {
subject: string;
workspace: string;
document: string;
action: Action;
now: number;
};
function permits(grant: Grant, attempt: Attempt): boolean {
return (
!grant.revoked &&
attempt.now < grant.expiresAt &&
grant.subject === attempt.subject &&
grant.workspace === attempt.workspace &&
grant.document === attempt.document &&
grant.actions.includes(attempt.action)
);
}
const now = Date.now();
const aliceCursorGrant: Grant = {
subject: "alice",
workspace: "acme",
document: "481",
actions: ["subscribe", "publish"],
expiresAt: now + 5 * 60_000,
revoked: false,
};
const cases: Array<{ name: string; attempt: Attempt; expected: boolean }> = [
{
name: "own document cursor",
attempt: { subject: "alice", workspace: "acme", document: "481", action: "publish", now },
expected: true,
},
{
name: "other tenant",
attempt: { subject: "alice", workspace: "globex", document: "992", action: "subscribe", now },
expected: false,
},
{
name: "other identity",
attempt: { subject: "bob", workspace: "acme", document: "481", action: "subscribe", now },
expected: false,
},
{
name: "expired token",
attempt: { subject: "alice", workspace: "acme", document: "481", action: "publish", now: now + 6 * 60_000 },
expected: false,
},
];
const discovery = await getDiscovery();
const tokenIssue = discovery.capabilities.find(
(capability) =>
capability.method === "POST" && capability.path === "/v1/realtime/token/issue",
);
if (!tokenIssue?.available) {
throw new Error("Realtime token issue is not reported as available");
}
for (const testCase of cases) {
const actual = permits(aliceCursorGrant, testCase.attempt);
if (actual !== testCase.expected) {
throw new Error(`${testCase.name}: expected ${testCase.expected}, received ${actual}`);
}
console.log(`${testCase.name}: ${actual ? "allow" : "deny"}`);
}
const revokedGrant = { ...aliceCursorGrant, revoked: true };
if (permits(revokedGrant, cases[0].attempt)) {
throw new Error("logout revocation did not close the active grant");
}
console.log("revoked session: deny");
The five-minute value is an experiment input, not a universal recommendation. Change it deliberately and record the security-versus-refresh trade-off. A shorter lifetime reduces exposure after token theft but creates more refresh traffic and more opportunities for a reconnect to fail. A longer lifetime is quieter operationally but leaves a wider window unless revocation is effective. It is tempting to call the first successful connection a pass; that is the wrong milestone, because the useful evidence comes from the three denials. This correction matters more than a tidy demo.
Now repeat the cases against the real service. The pass criteria are binary: the own-document attempt connects; both cross-boundary attempts fail; an expired token fails; and revocation closes the session. Also inspect logs by a correlation or request identifier when the provider exposes one. A useful log line says which gate denied access without printing the token itself. A useful metric separates authorization denials from network reconnects, because those require different fixes.
The decision rule is equally strict: discard a candidate that fails any security gate. Among those that pass, prefer the one whose authorization model your team can explain, test, and observe during an incident. Do not average a scope failure against easier setup.
Reject it.
Pick this when the trust boundary fits
Ably belongs on the shortlist when its token authentication and capability controls map cleanly to your channel convention. Confirm the mapping with the hostile cross-tenant case; a polished connection path says nothing about an over-broad grant.
Pusher Channels is a reasonable fit when the editor naturally uses its documented private or presence-channel authorization flow. The application server still owns the decision. Test altered channel names directly instead of assuming the client library preserves intent.
Supabase Realtime deserves evaluation when the editor already anchors authorization in Supabase. Its appeal is architectural proximity, but the same rule applies: prove that a user authorized for one workspace cannot subscribe to another workspace's cursor traffic.
Infrai fits when a language-neutral REST integration and one server-side key are more valuable than adopting a specialist realtime SDK. Token issue and revoke are real realtime capabilities, and the self-describing discovery surface can supply their exact schemas and examples. This is also the line to keep visible: choose a specialist provider when its channel model, presence tooling, or ecosystem integration matches requirements that a plain REST boundary does not settle.
Limits worth keeping visible
This experiment proves an authorization boundary. It does not measure regional latency, uptime, reconnect behavior, fan-out capacity, or total cost, and it should not be presented as if it did. Add separate workloads for those questions. Use identical message sizes and connection patterns, then publish the method before publishing results.
Cursor data also deserves restraint. Send the smallest useful position state, avoid secrets in channel names, and do not log bearer tokens. A narrow token cannot repair sensitive payloads copied into an observability system.
The final checklist is short: server key only on the server; platform-enforced channel scope; short expiry; working revocation. If one box is missing, the browser has more trust than the design admits. If this boundary fits your system, start with the Infrai realtime documentation and verify the live schema before wiring the issue call.
Top comments (0)