TL;DR: Mint a short-lived token whose claims name one channel and a viewer-only role, then enforce those claims at the media and event boundaries. Treat presence as server-owned state, not as proof that a token is valid. This keeps a live-score room readable while preventing a browser from publishing, mutating membership, or replaying a stale session.
For an edtech score stream, my decision rule is plain: a viewer token should expire quickly, carry the smallest useful scope, and be useless outside the channel it names. The Node.js/Express endpoint that issues it is only a policy checkpoint; the realtime service must check the same policy again. A token that says viewer is evidence of authorization, not evidence that a student is still connected.
One channel.
One role.
One expiry.
What should a viewer token actually allow?
A shared channel usually carries two kinds of data: the score event itself and the presence view around it. They have different trust boundaries. A read-only client can receive score updates and a deliberately coarse roster, but it should not publish a score, signal a peer, alter a roster record, or renew itself indefinitely.
Keep the claims boring and explicit. I normally include a subject, channel identifier, role, audience, issued-at time, and expiry. The channel identifier is a resource check, not decoration. The audience check prevents a token minted for one realtime service from being accepted by another service that happens to share the signing key.
No exceptions.
A 60-second lifetime is a useful starting point for a classroom stream, then the client asks the application for another token while the session is still authorized. The exact lifetime belongs in capacity and threat reviews: shorter lifetimes increase mint traffic, while longer lifetimes extend the window for a leaked browser token. Do not put a mutable permission such as can_publish in a cache and assume it will change everywhere at once.
That is the trap.
How should Node.js read-only tokens protect shared channel viewers?
Presence is a distributed observation. A viewer can close a tab without sending a final event, lose Wi-Fi after a heartbeat, or reconnect through a second browser before the first connection is noticed. Token validation answers “may this connection read channel A?” It does not answer “is this learner watching right now?”
Use a server-side connection record keyed by a stable session identifier, with a heartbeat deadline and a monotonically increasing connection epoch. On connect, the realtime service records the epoch. On disconnect or timeout, it removes only the matching epoch, so a delayed close from connection one cannot erase connection two. Broadcast presence changes from that authoritative store, not from client claims.
For example, imagine learner 17 opens a score room on a laptop, then switches to a tablet when the laptop sleeps. The tablet receives epoch 42 and starts heartbeats. A delayed network close from the laptop arrives with epoch 41. If the delete operation matches only the learner ID, the roster briefly says learner 17 is absent even though the tablet is active. Matching the epoch makes the stale close harmless, and the metric for roster lag tells you how long the old observation remained visible. This is a small data-model choice with a large effect on presence accuracy during ordinary classroom transitions.
This distinction matters operationally. If the score feed has a 99.9% availability SLO but presence is allowed to lag for 15 seconds, those are two separate objectives and should have separate measurements. Track token mint latency, rejected scope checks, heartbeat age, duplicate session rate, and the delay between a connection event and the roster update. A green request rate can hide a stale roster.
A narrow token endpoint in Express
The endpoint should resolve the learner and channel from your application database, apply the classroom policy, and call one signing abstraction. It should never accept role or channel as trusted values copied from the request body. The example below shows the shape of that boundary in Go; the same contract can sit behind an Express route, with the signing key held outside source control.
package token
import (
"context"
"net/http"
"time"
)
type Claims struct {
Subject string
Audience string
Channel string
Role string
IssuedAt time.Time
ExpiresAt time.Time
}
type Signer interface {
Sign(context.Context, Claims) (string, error)
}
func IssueViewerToken(ctx context.Context, w http.ResponseWriter, signer Signer, learnerID string, channelID string, allowed bool) {
if !allowed || learnerID == "" || channelID == "" {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
now := time.Now().UTC()
claims := Claims{
Subject: learnerID,
Audience: "score-realtime",
Channel: channelID,
Role: "viewer",
IssuedAt: now,
ExpiresAt: now.Add(60 * time.Second),
}
token, err := signer.Sign(ctx, claims)
if err != nil {
http.Error(w, "token service unavailable", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"token":"` + token + `","expires_in":60}`))
}
In the realtime handshake, validate signature, audience, expiry, channel, and role. Then map viewer to an allow-list of operations. The allow-list should be enforced for every message type, because a websocket that was authorized at connect time can still receive a crafted publish frame later. Reject an out-of-scope channel before touching presence state.
Do not log the raw token. Log a hash of its identifier, the subject, channel, decision, and reason. Redact authorization headers in reverse-proxy logs as well. Key rotation needs an overlap window: accept the previous verification key long enough for issued tokens to expire, then remove it.
How do we verify the safe path and roll it back?
Start with contract tests around the policy, not around a particular vendor SDK. A valid viewer token can subscribe to the score stream and receive a roster snapshot. The same token must fail a publish attempt, a subscription to another channel, an audience mismatch, and an expired timestamp. Add a reconnect test that opens two sessions for one learner and verifies that closing the older session does not remove the newer one.
Load tests should model the mint burst at class start, not just a smooth average. If 4,000 learners enter within 30 seconds, size the endpoint, signer, key store, and realtime handshake path for that burst while preserving the token-mint SLO. Watch event-loop delay in the Node.js process, queue depth in any signer worker, and heartbeat processing latency. Presence accuracy is a time budget; write down the maximum tolerated stale interval and alert on it.
Roll out behind a server-side flag. During a canary, compare authorization denials and roster lag with the previous policy, sampling by classroom rather than by individual user so one class sees one consistent behavior. Rollback should disable issuance of the new claim set and leave verification for the previous set available until its maximum token lifetime has elapsed. That order avoids stranding already connected viewers while the application reverts.
Managed service or self-hosted realtime?
The decision is less about feature checklists than about who owns the failure budget. A managed transport can reduce on-call work but adds a boundary where presence events, identity claims, and retention rules must be mapped. Self-hosting gives direct control over those mechanics and shifts capacity planning, patching, and incident response to the platform team.
| Concern | Managed transport | Self-hosted transport |
|---|---|---|
| Presence accuracy | Verify event ordering, timeout semantics, and regional behavior in a test tenant | Tune heartbeat, fan-out, and failover behavior directly |
| On-call load | Fewer servers; provider incident is still your incident to explain | Full ownership of saturation, upgrades, and recovery |
| Cost shape | Usage-based spend can track enrollment bursts | Fixed capacity plus engineering and operations time |
| Lock-in | Adapter must preserve your channel and claim model | Protocol and deployment choices remain yours |
Whichever path you choose, keep the authorization contract in your application. Then a transport change does not require changing classroom policy, and a presence incident can be diagnosed from your own metrics instead of inferred from a provider dashboard.
There is a real trade-off here. A managed transport is a poor fit when you need packet-level diagnostics, custom retention, or deterministic behavior during a regional partition; its event semantics become another dependency to test. Self-hosting is not suitable for a small team without capacity for patching, failover drills, and 24-hour response. Those limitations should be written into the decision record instead of hidden behind a feature checklist.
The practical endpoint is small: one channel, one role, one short expiry. The surrounding discipline is larger. Separate authorization from presence, measure both against explicit SLOs, and make rollback preserve the overlap between old and new keys. That is enough to keep a live score room readable for legitimate viewers without granting a browser more authority than it needs.
Top comments (1)
The separation between authorization and presence is important. I would include an audience claim and a server-side token-version or revocation check for high-value rooms, then test that a captured token cannot subscribe to a second channel or publish an event. Those tests make the one-channel/one-role model enforceable rather than descriptive.