The Node.js Express service pages because viewers with a read-only token are trying to publish into a shared cursor channel. The on-call opens an event sample from the property-management editor and sees the authorization boundary failed before the fan-out layer had to defend itself.
TL;DR: In a Node.js Express service, issue a short-lived, signed capability token that binds one authenticated viewer to one document and the single action subscribe. Validate its signature, expiry, audience, document, and action during the connection handshake; enforce the capability again for every client-originated message. Treat cursor delivery as ephemeral, ordered state rather than a durable command stream. A viewer may receive cursor positions, but a viewer token must never authorize publishing, presence mutation, history access, or a wildcard document.
The least complex design is one token, one document, one direction. It gives the on-call a useful page: rejected publish attempts grouped by reason, without putting token contents or user identifiers into the alert. It also prevents a reconnect from quietly widening access because a client reused a credential minted for an earlier tab.
How should Node.js issue read-only tokens for viewers on a shared channel?
A role such as viewer is too vague at the realtime boundary. Elsewhere, it may mean permission to comment, inspect revision history, or appear in a presence roster. The fan-out service needs a narrower statement: this principal may subscribe to cursor events for document property-482-inspection, until a specific expiry, through the intended realtime audience.
Use explicit claims with boring meanings:
| Claim | Example | Rejection condition |
|---|---|---|
| subject | opaque account identifier | missing or no longer permitted |
| audience | cursor-stream |
any other service audience |
| resource | property-482-inspection |
requested document differs |
| actions | [subscribe] |
publish or an unknown action appears |
| expiry | short absolute timestamp | expired at verification time |
| token ID | random opaque identifier | revocation policy rejects it |
Do not encode document:*. A property manager with access to one inspection record should not inherit cursor visibility across every building merely because all documents share a transport. Channel names are routing labels, not proof.
The Express endpoint that mints the credential should run behind the application's ordinary session authentication and current document authorization. It returns the capability only after both checks pass. The realtime handshake verifies the signed value independently and derives the subscribed document from the verified resource claim, not from an untrusted query parameter. That distinction looks fussy until a client asks for document B while presenting a token for document A.
No fallback. Reject it.
Silence matters.
Why did the fan-out page fire?
Work backward from the rejection. A useful event records a stable reason such as action_denied, the token audience, the requested operation, the document class, and a correlation ID. It does not record the raw token. The preceding signal should have been a rising ratio of denied client publish attempts to active viewer connections, split by application release and protocol version. That points toward a bad client rollout or an authorization regression before aggregate fan-out failures become noisy enough to wake someone.
The verifier belongs at two boundaries. First, reject an invalid capability during connection setup. Second, reject every inbound operation that the established connection is not allowed to perform. Connection-time validation alone assumes that possession of a socket makes all later messages legitimate, which turns a read-only token into a write credential as soon as the message router forgets to check.
Here is the policy core in Go, isolated from transport details so it can be tested against the same cases as the Node.js middleware. The Node.js layer should supply already verified claims; this function expresses the authorization decision, not signature parsing.
package capability
import (
"errors"
"time"
)
type Claims struct {
Subject string
Audience string
Resource string
Actions map[string]bool
Expires time.Time
}
func Authorize(c Claims, document, action string, now time.Time) error {
if c.Subject == "" {
return errors.New("subject_missing")
}
if c.Audience != "cursor-stream" {
return errors.New("audience_mismatch")
}
if !now.Before(c.Expires) {
return errors.New("token_expired")
}
if c.Resource != document {
return errors.New("resource_mismatch")
}
if action != "subscribe" || !c.Actions[action] {
return errors.New("action_denied")
}
return nil
}
The deliberately strict action != "subscribe" check protects against a minting mistake that accidentally adds publish to the action set. Defense here costs a branch and buys a clearer incident boundary. The trade-off is reduced policy reuse: an editor credential needs a separate policy path. That is acceptable because mixing reader and writer semantics is exactly the failure this boundary exists to stop.
Delivery guarantees at cursor fan-out
Authorization answers who may do what. It does not answer what a viewer should observe when packets duplicate, arrive late, or disappear. Collaborative cursors are transient projections of newer state, so blindly retrying every coordinate can make the screen less correct: an old retry may move a cursor backward after a newer position was rendered.
Attach a monotonically increasing sequence number per publishing editor session and a stable session identifier to each cursor event. A viewer keeps the highest accepted sequence for that session and drops anything older or equal. After reconnect, the server sends the latest known cursor snapshot, followed by newer events. The snapshot is bounded state, not an audit log. When an editor disconnects, its cursor should age out under an explicit presence policy instead of lingering forever.
This provides at-most-once application of each sequence at the viewer, even if the transport delivers a message more than once. It is not proof of exactly-once network delivery. Avoid that claim. A delivery path can acknowledge, retry, reconnect, and still leave uncertainty around a connection failure; idempotent state application is the useful guarantee.
Freshness wins.
WebRTC data channels expose ordered delivery and retransmission controls, as specified by WebRTC 1.0. Those controls change latency and loss behavior, but they do not replace authorization or application sequence checks. A connection-oriented transport may preserve message order on one live connection, yet a reconnect creates a new boundary where the application still needs a snapshot and session-aware sequencing. Choose the transport after defining the state contract.
For property editing, keep cursor movement lossy and freshness-biased, while document edits follow a separate durable and conflict-aware path. The reason is operational: losing one intermediate pointer coordinate is tolerable; losing a lease amendment is not. Combining them under one retry and retention policy makes both harder to reason about during an incident.
Testing the boundary before deployment
Table tests should cover expired credentials, the wrong audience, the wrong property document, missing subject, publish, unknown actions, and a valid subscribe. Add generative tests asserting that no action other than subscribe can succeed, regardless of the action set supplied in claims. The ugly case is more valuable than the happy path: a valid viewer token for document A paired with a request for document B must fail.
Exercise reconnects too. Deliver sequences 41, 43, 42, and 43 again; the viewer should apply 41 and 43, then discard the stale and duplicate events. Restart the fan-out process, request a snapshot, and confirm that the client does not interpret it as permission to publish. Rotate the signing key while existing short-lived credentials remain within the deliberately defined verification window, then verify that an unknown key identifier fails closed.
Deployment should separate minting metrics from connection and message metrics. Watch issuance failures, handshake denials by stable reason, denied operations on established connections, reconnect rate, snapshot age, and cursor event lag. High-cardinality document or account identifiers do not belong in metric labels; retain correlation IDs in access-controlled logs instead. The dashboard can be wrong or stale. Ask what page fired, what user-visible condition it represents, and which bounded action the on-call can take.
Where should the alert threshold sit?
A single denied publish from a viewer is evidence that a boundary worked, not necessarily an incident. Paging on every rejection trains the rotation to ignore the alert. Page when the ratio and duration indicate broad user impact or a security-relevant behavior change, and route isolated denials to investigation without waking anyone. The exact threshold must come from baseline traffic and an explicit error budget; no universal percentage is defensible here.
Instrument the earlier signal first: viewer publish denials by release and protocol version. Pair it with a symptom signal such as a sustained failure of authorized viewers to receive fresh snapshots. This keeps a buggy client from creating a security-shaped page while ensuring a real authorization outage still reaches the on-call.
There is a cost on both sides. A threshold that is too low burns attention on successfully blocked traffic. One that is too high hides a rollout that converted legitimate subscribers into rejected connections. Review the alert after each material protocol change, keep reason codes stable, and make the runbook begin with the authorization boundary rather than a wall of transport charts.
The decision rule remains small: mint one-resource, subscribe-only capabilities; verify at connection and message time; sequence ephemeral cursor state; and alert on sustained user impact plus a diagnostic denial signal. Anything broader needs a written reason because it expands both the blast radius and the 3 a.m. search space.
Further reading
- W3C, WebRTC 1.0: https://www.w3.org/TR/webrtc/
Top comments (0)