DEV Community

FinnOakley52947
FinnOakley52947

Posted on

RTC Tokens and Shared Join Authority — 3 Editor Presence Failures

Use room-scoped, expiring tokens when a media editor must keep collaborative cursors accurate after membership changes; reserve a shared join link for sessions where possession of the URL is intentionally enough to enter. TL;DR: the decisive constraint is not connection setup. It is whether the server must distinguish one participant, device, role, and expiry from another while a video call and editor presence share a room.

A link is wonderfully cheap in developer attention. Paste, click, join. But once everyone presents the same secret, the authorization layer has little evidence for explaining who is still allowed to publish presence. A token adds an issuance step and validation code, yet it gives the room boundary explicit inputs.

This is a lifecycle problem. WebRTC defines browser-side real-time connection APIs, but an application still has to arrange signaling and decide who may join its session. Treating the invitation URL as both discovery and durable authority hides that decision in a string that people copy everywhere.

Should RTC room tokens replace a shared join link?

A collaborative cursor is a claim: participant p_17 is active in document cut_42, at revision 918, near character 603. The video tile beside it is another live capability. Both may travel through the same conceptual room, but they do not have identical permissions or lifetimes. Presence should disappear when the participant disconnects, loses editor rights, leaves the document, or is replaced by a newer connection.

A shared join link can represent a session-level grant. That works for an open production review where anybody holding the URL may enter under the same policy. It gets awkward when the application needs per-participant answers. Which device should win after a reconnect? Can a guest publish a cursor or only receive the call? Does removing one contractor invalidate access for everybody who has the URL? If the only credential is shared, selective revocation requires another identity layer or rotating the shared secret and redistributing it. The glue returns.

A room token can carry, or point to, the narrower decision: subject, room, allowed actions, issuance time, expiry, and a unique identifier. The server must still validate those fields and bind them to its own room state. A signed blob is not magic. It is a compact input to an authorization check.

Failure mode Shared join link Room-scoped token Presence consequence
URL is forwarded New holder has the shared grant unless another check intervenes New holder still needs a valid participant grant Shared authority makes unattributed cursors easier to admit
One member is removed Selective removal needs separate identity state or link rotation Reject that subject or token identifier The removed cursor can expire without ejecting the room
Client reconnects twice Both connections present the same room secret Compare subject, device, token ID, and connection generation One canonical cursor can replace the stale one

Long-lived bearer tokens copied into logs recreate the shared-link failure with extra ceremony. Bad inputs, same result.

That limitation matters.

The smallest implementation I would ship

I would keep invitation and authorization separate. The invitation URL contains an opaque lookup key. Opening it asks the application server for a short-lived room grant after the normal participant check. The real-time gateway validates that grant, then creates a server-owned presence lease. Cursor messages never choose their own participant identity.

type RoomAction = "call:join" | "presence:read" | "presence:write";

type RoomGrant = {
  subject: string;
  roomId: string;
  deviceId: string;
  actions: RoomAction[];
  issuedAtMs: number;
  expiresAtMs: number;
  grantId: string;
};

type CursorUpdate = {
  documentId: string;
  revision: number;
  anchor: number;
  focus: number;
};

function mayPublishCursor(grant: RoomGrant, update: CursorUpdate, nowMs: number): boolean {
  return (
    nowMs >= grant.issuedAtMs &&
    nowMs < grant.expiresAtMs &&
    grant.actions.includes("presence:write") &&
    update.documentId === grant.roomId &&
    Number.isSafeInteger(update.revision) &&
    update.revision >= 0 &&
    update.anchor >= 0 &&
    update.focus >= 0
  );
}
Enter fullscreen mode Exit fullscreen mode

This deliberately omits token encoding and signature algorithms. Those details depend on the issuer and verifier under your control, and pretending one snippet settles key rotation would be dishonest. The important boundary is visible: identity and room scope come from a verified connection, while position and revision come from the untrusted message.

The server also needs one winner for reconnects. A monotonically increasing connection generation is easier to reason about than arrival time because packets and cleanup events can be delayed.

type PresenceLease = {
  subject: string;
  deviceId: string;
  connectionId: string;
  generation: number;
  expiresAtMs: number;
};

function chooseLease(current: PresenceLease | undefined, next: PresenceLease): PresenceLease {
  if (!current) return next;
  if (next.subject !== current.subject) return current;
  if (next.deviceId !== current.deviceId) return current;
  return next.generation > current.generation ? next : current;
}
Enter fullscreen mode Exit fullscreen mode

This is intentionally boring TypeScript. I judge an authorization design by how quickly another developer can locate the denial and by how few configuration files stand between a request and that decision. If the policy needs a diagram to explain why a cursor may move, it is already too clever.

Test the three ugly transitions

Happy-path join latency is the easy benchmark. Measure the transitions that make presence lie: removal, reconnect, and expiry. The useful interval runs from the authoritative membership change to the last accepted cursor update from the old connection. Separately measure how long other clients display that cursor. Those timestamps expose server rejection and client cleanup as different problems.

Start with deterministic state tests, then repeat them across the deployed signaling path with controlled clocks and packet delay. Record grant ID, subject, room, connection generation, decision, and reason at the authorization boundary. Never log the bearer credential or invitation secret. A counter for each rejection reason beats one generic authorization-error total.

Shared links need equivalent tests. Forward the URL into a clean browser profile. Remove one participant without rotating the link. Reconnect the same device twice. If an additional login or membership lookup prevents the unwanted join, count that component in the shared-link design; it is part of the authorization system, even if the URL still looks simple.

Cost means moving parts, not a teaser price

There is no stable universal price answer. Infrastructure charges depend on traffic and deployment, while engineering cost depends on identity, revocation, observability, and support expectations. Compare the machinery your session requires.

A shared link has one obvious issuance event and minimal UI. Its hidden costs appear when the link escapes its intended audience or support needs to remove one person without disrupting everyone else. Link rotation creates coordination work. Adding participant identity later creates another service boundary.

Tokens require an issuer, validation, clock handling, key management, and clear failure responses from day one. That is real complexity. They also make selective policy explicit. The token path pays fixed control-plane complexity to gain finer failure isolation.

The trade-off is blunt: token validation adds a control-plane dependency to every admission or renewal. If that issuer is unavailable, new participants may be unable to enter even while existing media connections remain healthy. A shared link avoids per-participant issuance and is therefore a better fit for a disposable, open review where the organizer accepts room-wide rotation as the recovery tool. It is a poor fit when one editor must be removed without changing access for the other 19 people in the room. Tokens have the inverse boundary: they support that narrow removal, but the team now owns expiry policy, clock skew handling, signing-key rotation, denial telemetry, and a client state for renewal failure. Count all of it. A token design without those pieces is unfinished authorization, not lower complexity.

No shortcut here.

For a short, open video review with no private edit capability, the shared link can be the honest choice. For a media workspace where callers move cursors, annotate a cut, or change roles, per-participant grants fit the actual permissions. Both authorization patterns can lead to the same WebRTC media session, so the meaningful delta lives in access control and operations.

What I would change at scale

First, split call admission from editor presence. A participant might retain call:join while losing presence:write; forcing both through one boolean makes moderation crude. The client should render an explicit read-only state after that downgrade rather than retrying writes forever.

Second, keep active leases in authoritative room state and expire displayed cursors through bounded leases, not only disconnect events. Browsers sleep. Networks partition. Cleanup messages vanish. An expiry gives every replica a conservative point after which stale presence is no longer credible.

Third, benchmark authorization at the percentile that affects reconnect bursts, but publish the test conditions beside the number: room size, validation path, cache state, token size, and whether signature verification occurred. Track time to first accepted cursor separately from time to first media connection. Combining them can hide a slow editor path behind a healthy call.

The decision remains compact. Use a shared join link when possession is the intended room policy and rotating the whole room is an acceptable response. Use scoped tokens when accuracy requires participant-level expiry, revocation, roles, or reconnect ownership. Keep invitations shareable if the product needs that convenience; do not confuse discovery with continuing authority.

References

Top comments (0)