Issue a scoped realtime token to a browser client from Node.js only after Express authentication middleware has established the requester and the server has checked membership in the requested customer-support conversation. Short answer: the browser may carry a short-lived credential for its own conversation, but it must never choose the credential's subject, channel scope, or signing key. Treat typing as disposable state and receipts as durable, authorized updates; the two signals have different recovery requirements.
How should Express middleware issue a scoped realtime token to a browser client?
An authenticated support agent can still be the wrong agent for a particular ticket. A cookie proves a session; it does not prove membership in every conversation whose identifier the client can put in a request. If an Express endpoint signs whatever conversationId arrives in JSON, an agent can request a credential for somebody else's thread. The signature then makes an authorization mistake look legitimate downstream.
The trust boundary sits at issuance, not at socket connection. Authenticate the HTTP request, look up the conversation and its current participants, decide which signal operations that participant may perform, and only then sign a credential. Bind the resulting claims to one conversation and one authenticated subject. A support reassignment is a particularly sharp test: a previously valid credential may remain usable until expiry unless the verifier checks current membership or a revocation epoch. Short expiry limits that window but does not erase it. Consider an agent who can read conversation A at issuance and is reassigned to conversation B a minute later: a five-minute credential for A still has four minutes of nominal validity. If the verifier does not consult current membership, the issuance policy alone cannot close that gap. Reduce the lifetime, introduce revocation, or accept and document the exposure window before rollout.
This is the trap.
Do not put message bodies in typing events. Even a typing indicator discloses activity and participation, so a subscriber needs the same conversation-level read authorization as someone viewing the thread. Read receipts need a further check: the caller can advance only its own read position, never another participant's.
Put authorization before the signer
The following Go contract captures the server-side decision even if the HTTP application happens to use Express. It deliberately leaves signing and storage behind interfaces: copying a signature algorithm into a tutorial is a poor substitute for selecting a verified implementation and defining key rotation. Express middleware should establish the authenticated subject before its issuance handler calls an equivalent policy function; client-supplied subject IDs are ignored.
package signals
import (
"context"
"errors"
"time"
)
type Membership interface {
CanRead(ctx context.Context, subject, conversation string) (bool, error)
}
type Claims struct {
Subject string
Conversation string
Operations []string
ExpiresAt time.Time
}
func Issue(ctx context.Context, members Membership, subject, conversation string, now time.Time) (Claims, error) {
if subject == "" || conversation == "" {
return Claims{}, errors.New("missing identity or conversation")
}
allowed, err := members.CanRead(ctx, subject, conversation)
if err != nil {
return Claims{}, err
}
if !allowed {
return Claims{}, errors.New("conversation access denied")
}
return Claims{
Subject: subject, Conversation: conversation,
Operations: []string{"typing:publish", "signals:subscribe"},
ExpiresAt: now.Add(5 * time.Minute),
}, nil
}
Five minutes here is an example policy, not a measured optimum. Match expiry to reassignment risk, reconnect frequency, and your ability to revoke. Keep receipt writes on an authenticated server endpoint until its authorization and monotonic-update rules are equally explicit; the sample credential cannot publish receipts. The limitation of short-lived bearer credentials is that expiry cannot provide immediate revocation. This design is not suitable for a deployment that requires immediate removal of access after reassignment but can enforce neither revocation nor membership checks at connection time. In that case, keep delivery behind a server that checks current membership on every authorized operation. The trade-off is additional server load and a larger on-call surface. If a delivery system's token language cannot express these operations separately, do not pretend a channel name alone provides the distinction.
In the browser, keep the credential in memory, request a replacement through the authenticated application session before expiry, and reconnect to the same authorized conversation. Do not put it in a URL, where logs and browser history can retain it. The issuing response should be non-cacheable; signing secrets remain on the server. For cross-site cookie sessions, the issuance endpoint also needs the application's normal CSRF defense.
Separate fleeting activity from persisted position
Typing is a hint. Give it a brief lifetime, rate-limit publishers, and let it disappear on disconnect or inactivity; replaying an old typing event after reconnect is misleading. Receipt state is different: persist the highest message position each participant has read, reject regressions, and return the stored position after reconnect. The message position must refer to a message in that same conversation, and the update must use the authenticated participant as its owner.
A transport can deliver duplicates or miss events around a disconnect. Make receipt updates idempotent and monotonic, then treat realtime delivery as a prompt to refresh authoritative state rather than as the only record. The browser should render a typing indicator only for another participant in the active conversation; it should not infer that a message was read just because a socket connected. For example, a receipt for position 18 can reach storage after one for position 19; a naive last-write-wins assignment regresses the read state. The authoritative update must compare the existing position and retain the greater valid position, with authorization and conversation membership checked in the same write path. Broadcasting position 18 after storage already holds 19 is harmless only if clients reconcile against the persisted value, rather than taking arrival order as truth.
Arrival order lies.
Here is the capacity-planning question I would ask before selecting a delivery mechanism: how many concurrent support conversations are open, how many clients observe each one, and what happens to publish rate when every keystroke produces an event? Coalesce typing changes at the source and enforce per-subject limits at the boundary. For receipts, measure write amplification independently of fan-out; batching cannot be allowed to move a read position backward. No universal throughput number answers this without a workload.
| Choice | Operational benefit | Boundary to check |
|---|---|---|
| Managed delivery | Less socket infrastructure on call | Can token scope express one conversation and distinct operations? |
| Self-hosted delivery | Direct control over verification and event retention | Who owns connection capacity, reconnect storms, and key rotation? |
| Server-mediated receipt writes | One place to enforce membership and monotonic state | Can the write path meet the receipt freshness objective? |
Verify the boundary before rollout
Start with a negative test: agent A requests a credential for agent B's conversation and gets no credential, even if the conversation identifier exists. Then test expired sessions, revoked membership, changed assignment, and two simultaneous receipt updates arriving out of order. A successful subscription to the correct thread does not prove isolation; attempt a subscription and publication to a neighboring thread with the issued credential. Check that denial responses do not reveal whether an unrelated conversation exists.
Deploy behind a small cohort of support conversations and watch issuance denials, unauthorized subscribe attempts, credential refresh failures, reconnect rate, and receipt lag. Set an SLO for receipt freshness using the stored position, not merely socket delivery, and separate an availability alert from a security alert: a spike in denied cross-conversation attempts needs investigation even if connections are healthy. Avoid logging credentials or full conversation payloads; record stable request and conversation identifiers under the team's data-retention policy.
Rollback is two-part. Disable new browser credential issuance and fall back to the existing authenticated refresh path for receipt state; typing indicators can be temporarily absent without losing messages. Revoke or wait out credentials already issued according to the configured verifier and expiry policy, then confirm that no stale credential can cross a reassignment boundary. Preserve persisted read positions during the rollback. A transport switch should never rewrite who read what.
Top comments (0)