TL;DR: If realtime messages aren't arriving, debug the token scope against the canonical channel name before touching retries or transport settings. A healthy connection proves reachability, not permission to receive a particular cursor stream.
| Choice | Client trust | Best fit | Main cost |
|---|---|---|---|
| Server-authorized room subscription | Low: server derives allowed rooms | Multi-tenant editors | More authorization code |
| Client-provided room plus server validation | Medium: server checks every requested room | Simple collaboration features | Canonicalization must stay consistent |
| Broad token with client-side filtering | High: client receives more than it needs | Controlled prototypes only | Larger privacy and blast-radius risk |
Recommendation: use a short-lived credential whose claims name the allowed document or room, then compare those claims with one canonical room key at subscription time. Log the comparison as structured metadata without logging the credential. This is the smallest design that makes a silent cursor failure explainable and keeps an untrusted browser from choosing arbitrary rooms.
For a one-person SaaS, this is a revenue-per-hour decision. A narrow contract takes a little more code today, but it prevents support work from consuming the week that should ship a feature. Outsource the undifferentiated transport machinery; keep authorization and tenant boundaries in code you can test.
How should I debug realtime messages that aren't arriving?
Connection state and subscription state answer different questions. The first says that two endpoints can exchange data. The second says that this principal may receive messages for this precise logical room. A connected client can therefore remain silent when tenant/acme/document/42 is authorized but the UI subscribes to tenant/acme/documents/42, when case normalization differs, or when the credential was minted for another document tab.
That distinction matters with peer data channels too. WebRTC defines data channels and their transport behavior, while the application chooses the channel label and the meaning of messages carried over it. The standard cannot decide that a user may read a B2B customer's document. Your application must bind identity, tenancy, document membership, and room naming before it accepts the subscription.
Start with one trace for one cursor movement. Record a request correlation ID, an irreversible hash of the user ID, the canonical room key, the authorization result, and the message type. Then ask where the trace ends. If publish acceptance is present but subscription authorization is absent, inspect credential parsing. If both are present under different room keys, inspect canonicalization. If both match and delivery is still absent, only then inspect lifecycle and transport.
This ordering is deliberate. Reconnect loops can hide an authorization defect by producing lots of activity, and widening a token can turn a typo into a cross-tenant exposure. Neither is a fix.
Start there.
Take one concrete failure path. The editor loads document 42, the authenticated control plane verifies membership, and the token allows document 42 inside tenant acme. The publisher constructs cursor:v1:acme:42. A subscriber copied an older helper that constructs cursor:v1:acme:document:42. Both strings look plausible in separate log lines, both sockets can be open, and a retry will reproduce the same silence forever. Put the expected and requested room hashes beside the authorization result in one trace and the mismatch becomes visible. Reject the join, return room_mismatch, and fix the shared room constructor. Do not broaden the token, alias both names, or subscribe to both rooms: each move hides the broken contract and makes the trust boundary harder to audit. This example also explains why transport dashboards come later. They can show two healthy connections while saying nothing about application-level room equality.
Criterion one: scope should describe the resource
A cursor token should answer which tenant and document may be joined. It should not trust a room copied from a query string merely because the browser supplied it. Treat every browser as an untrusted caller, even when the UI generated the value.
A compact claim can carry a subject, tenant, allowed document identifiers, and expiration. The exact encoding is an implementation choice. The invariant matters more: the server constructs the canonical room from verified claims and server-side membership data, or checks a requested room against that canonical value using an exact comparison.
Avoid broad wildcards for convenience. They reduce the number of authorization branches, but they also make it harder to distinguish a legitimate empty room from an accidental subscription elsewhere. Scope only what the cursor session needs. Keep notification delivery, document edits, and presence as separate capabilities when they have different readers or retention rules.
Small scope, small blast radius.
Expiration creates a second boundary. Check it before joining, return an explicit authorization outcome to the application, and refresh through the normal authenticated control plane. Do not let an expired credential degrade into a connected-but-unsubscribed state. Silence is expensive to diagnose.
Criterion two: one room name must survive every boundary
Room identity often starts as several values: a URL slug, a database ID, a tenant ID, and perhaps a temporary editor tab ID. Pick one canonical representation on the server. The publisher and subscriber must use the same function, byte for byte.
Good canonicalization is boring. It rejects missing segments, prevents delimiter ambiguity, and never changes case unless the underlying identifier is defined as case-insensitive. Version the format when its meaning changes. For example, cursor:v1:<tenant-id>:<document-id> makes the namespace and schema visible without granting anything by itself. The authorization decision still comes from verified membership and claims.
One function. Everywhere.
Do not normalize until two bad inputs happen to match. Lowercasing arbitrary IDs, trimming meaningful whitespace, or accepting both singular and plural path segments can mask a producer-consumer mismatch. Fail at the boundary with a reason code such as room_mismatch; keep the public response terse, and retain the safe diagnostic fields in logs.
A TypeScript contract that exposes the mismatch
The useful abstraction is not a vendor client. It is a checked subscription boundary. This example assumes the credential has already been cryptographically verified by the surrounding authentication layer; decoding unverified claims would defeat the entire design.
type VerifiedClaims = { subject: string; tenantId: string; documentIds: readonly string[]; expiresAtMs: number; }; type JoinResult = | { ok: true; room: string } | { ok: false; code: "expired" | "document_forbidden" | "room_mismatch" }; function cursorRoom(tenantId: string, documentId: string): string { if (!tenantId || !documentId || tenantId.includes(":") || documentId.includes(":")) { throw new Error("Invalid room segment"); } return `cursor:v1:${tenantId}:${documentId}`; } function authorizeCursorJoin( claims: VerifiedClaims, requestedDocumentId: string, requestedRoom: string, nowMs: number, ): JoinResult { if (claims.expiresAtMs <= nowMs) return { ok: false, code: "expired" }; if (!claims.documentIds.includes(requestedDocumentId)) { return { ok: false, code: "document_forbidden" }; } const expectedRoom = cursorRoom(claims.tenantId, requestedDocumentId); if (requestedRoom !== expectedRoom) return { ok: false, code: "room_mismatch" }; return { ok: true, room: expectedRoom }; } Keep message validation separate. A cursor payload needs a document-relative position, a sender identity derived from the authenticated session, and a bounded freshness policy. The server should not accept a sender ID from the payload as proof of identity. Nor should cursor coordinates be allowed to select a room.
Four focused tests buy more confidence than a large mocked demo: an authorized document joins; another document in the same tenant fails; the same document ID in another tenant fails; and an expired credential fails before subscription. Add a fifth case for a one-character room mismatch because that is the failure users experience as silence. Run these tests at the authorization boundary on every weekly release.
In production, measure authorization outcomes separately from transport state. Useful counters include join attempts by result code, accepted publications, delivered cursor messages, and active subscribers per canonical room. Keep identifiers hashed or otherwise minimized according to your privacy requirements. Never put raw credentials in logs, traces, analytics, or error reports.
The trade-off is explicit: exact resource scopes add token issuance and refresh paths, while canonical room enforcement adds a shared naming contract. This design is not a fit for anonymous public broadcasts where every connected client may read the same stream and resource-level authorization adds no boundary. It is also limited when participants connect without a server-mediated authorization step; in that case, the signaling layer still needs to establish identity and permitted session membership. Choose the narrower design for multi-tenant cursor data because a browser-selected room can cross a customer boundary, not because it minimizes code.
When is the runner-up design better?
Client-provided rooms with server validation are the runner-up. They are reasonable when room names are already stable public identifiers, membership rules are simple, and the server still performs an exact authorization check before attaching the connection. This can reduce mapping code for a small editor while preserving the important trust boundary.
A peer data channel may also fit a small, ephemeral editing session where participants are already authorized through a signaling flow and cursor data does not need server fan-out or durable recovery. WebRTC data channels can carry arbitrary application data, but signaling, identity, room authorization, and message semantics remain application concerns. Test connection setup, channel opening, closure, and rejoin as distinct states.
The broader-token option belongs in a tightly controlled prototype, if anywhere. It trades a few lines of policy for a larger trust surface and weaker diagnostics. For a B2B SaaS, that trade usually works against the weekly shipping rhythm: the first implementation looks quick, then every silent message demands manual inspection.
The final debugging rule is simple: prove identity, scope, and canonical room equality in that order. After those pass, investigate lifecycle, backpressure, and transport. This sequence fixes the class of error without granting the browser more authority than it needs.
References
The standards reference below defines WebRTC peer connections and data channels. Authorization claims, tenant membership, canonical room syntax, and observability conventions in this article are application-level design choices, not behaviors promised by that standard.



