Short answer: isolate token issuance from the chat data path, scope each token to one tenant, conversation, actor, and capability, then make reconnect depend on a durable cursor rather than on whatever the token service remembers.
For a property-management support desk, that rule separates two questions that are easy to muddle: “May this leasing agent join this resident conversation?” and “Which messages has this browser already received?” Authorization answers the first. A monotonically advancing message cursor answers the second. If one service owns both answers, a token refresh can quietly become a history-recovery mechanism, and an authentication interruption can turn into duplicate messages, missing context, or a cross-workspace disclosure.
The architectural decision is therefore narrow: the token service issues short-lived, audience-bound authority; the conversation service owns membership; the durable log owns backfill position; and the realtime edge validates authority without calling the issuer for every message. The exact lifetime is deployment-specific. I'm not sure a universal number exists, because revocation urgency, reconnect frequency, and mobile suspension patterns differ; a replay test with production-like disconnects should set it.
How should token service isolation work in a customer support chat?
Start with invariants, not components. A token accepted for Building A must never authorize a socket, media session, or backfill read for Building B. A resident token must not acquire an agent capability merely because both users are members of the same conversation. A refreshed token may extend authority, but it must not move the client's acknowledged message cursor. And a reconnect must be idempotent: repeating the same resume request yields the same ordered suffix, apart from messages appended after that request began.
Those invariants imply three independently checkable identifiers in the authorization claim set: tenant or workspace, conversation, and actor. Capability is separate. “Send text,” “read history,” and “join a call” are different grants even when one UI exposes them behind a single Join button. The realtime edge should reject a mismatch before subscribing the connection to a channel; the backfill reader should repeat the same tenant and conversation checks rather than trusting that the edge already did them. Defense in depth matters here because the edge and history path fail differently.
The token service should know enough to mint constrained authority, but it shouldn't become the system of record for chat progress. In particular, don't store last_seen_message_id inside a refresh-token session and treat that value as the resume cursor. Two tabs can acknowledge different messages, a suspended phone can return after the desktop has advanced, and an agent can legitimately open the same case on two devices. Cursor ownership belongs to the client/device pair or to a server-side acknowledgement record explicitly keyed by that pair — not to the authorization session.
Keep the boundary boring.
A clean request sequence is: authenticate the user; verify current conversation membership; issue a narrowly scoped token; validate it at connection admission; stream new events; persist acknowledgements independently; and, after a disconnect, read from the durable log after the last acknowledged cursor. Token renewal and log replay can occur near each other in time, but neither should mutate the other's state.
Invariants and failure boundaries
Isolation earns its keep when a dependency degrades. The token issuer can be temporarily unreachable while an already admitted connection continues until its local authority expires; the realtime edge can disconnect while durable messages remain available for replay; the browser can lose its in-memory socket while retaining a committed cursor. These are design boundaries, not promises that failure disappears. The catch is that local validation also delays the effect of revocation until the token expires unless the system adds a separate revocation signal, so highly sensitive workspaces may need shorter lifetimes or active connection termination.
Name the failure modes before choosing a topology:
- Scope confusion: a valid signature is accepted without checking tenant, conversation, audience, actor, and capability together.
- Refresh drift: renewal changes subscription state or advances the cursor, coupling authorization to delivery.
- Replay gaps: the edge retains only an ephemeral buffer, so a reconnect after that buffer is gone cannot reconstruct the ordered suffix.
- Duplicate application: the client retries backfill and applies the same message twice because message identity is not used as an idempotency key.
- Split admission: a connection is admitted under one claim set, then a history request is authorized using a broader session cookie.
One failure deserves a longer walk-through. Imagine an agent handling a resident's maintenance request, switching from a laptop to a phone as they leave the office. The laptop has durably acknowledged cursor m_1842; the phone was suspended at m_1817; meanwhile, the agent's access token has expired. The phone must first obtain fresh authority for the same tenant and conversation, then ask the log for events after m_1817. The response may include events the laptop already displayed, and that is fine: device-local application is keyed by message ID, while any shared “read” state is a separate domain event. If renewal instead copies the account-wide cursor m_1842 into the phone session, messages m_1818 through m_1842 vanish from that device's reconstructed view. Nothing in the token signature reveals the mistake. Only a reconnect test with two device cursors catches it.
Ouch.
Observability should mirror these boundaries. Record a reason code for admission denial, token renewal, replay start, replay count, duplicate suppression, and cursor advancement, while excluding token bodies and resident message content. Correlate them with an opaque connection ID and conversation ID. An alert on repeated scope mismatches has a different owner from an alert on growing replay lag; combining both into “chat connection failed” wastes the isolation the architecture created.
Comparing isolation patterns
The useful comparison is not “microservice versus monolith.” It is where authority is checked, what continues during a dependency interruption, and which component can corrupt delivery state.
| Pattern | Admission and message checks | Reconnect and backfill behavior | Principal limitation | Suitable use |
|---|---|---|---|---|
| Dedicated issuer, local token validation, durable log | Issuer verifies membership before minting; edge and history reader validate scoped claims | Client resumes from an independently stored cursor | Revocation can lag until expiry without an active revocation channel | Multi-tenant support chat where containment and independent scaling matter |
| Dedicated issuer, online introspection for each admission | Edge asks the authority service at connect time | Cursor remains independent, but new admission depends on the authority service | Adds a synchronous dependency to reconnect | Environments requiring immediate centralized policy decisions |
| Shared application session and chat state | One application checks a server session | Application can replay from its own database cursor | Isolation is organizational rather than a separately enforceable boundary | Small, single-tenant deployments with one team and modest failure domains |
| Token carries delivery progress | Edge derives resume position from refreshed authority | Refresh implicitly selects a backfill point | Couples security lifecycle to device delivery state | Rarely appropriate; possibly a disposable, single-device feed with no history guarantee |
The first pattern is the default decision here, but it is not universally best. It adds key distribution, claim-version management, clock-skew policy, and more integration tests. A small internal desk with one tenant, one deployment unit, and no independent scaling requirement may be better served by the shared-session pattern; isolation there can mean strict module and database boundaries rather than another network service. Stick with online introspection when immediate policy changes outweigh reconnect independence. There is no free topology.
Cost follows the same boundary. Local validation reduces synchronous authorization traffic but creates operational work around key rotation and cache freshness; introspection centralizes policy but places an authorization call on admission; a shared application is simpler to run but broadens the impact of an application-level authorization error. Estimate with connection churn, renewal rate, backfill reads, and retained event volume. Message throughput alone is the wrong denominator.
The reconnect and backfill critical path
The following Python sketch is deliberately a domain boundary, not a framework tutorial. verify_token must validate signature, issuer policy, audience, expiry, and the required claims; membership.is_current prevents an old but otherwise valid conversation grant from bypassing present membership policy. The cursor is supplied independently and constrained by the same conversation scope.
from dataclasses import dataclass
from typing import Iterable, Protocol
@dataclass(frozen=True)
class Claims:
tenant_id: str
conversation_id: str
actor_id: str
capabilities: frozenset[str]
@dataclass(frozen=True)
class Message:
message_id: str
cursor: str
conversation_id: str
body: str
class Membership(Protocol):
def is_current(self, tenant_id: str, conversation_id: str, actor_id: str) -> bool: ...
class MessageLog(Protocol):
def read_after(self, tenant_id: str, conversation_id: str, cursor: str) -> Iterable[Message]: ...
def resume_chat(
raw_token: str,
requested_tenant: str,
requested_conversation: str,
device_cursor: str,
membership: Membership,
log: MessageLog,
) -> list[Message]:
claims = verify_token(raw_token, expected_audience="support-chat")
expected_scope = (claims.tenant_id, claims.conversation_id)
requested_scope = (requested_tenant, requested_conversation)
if expected_scope != requested_scope:
raise PermissionError("scope_mismatch")
if "read_history" not in claims.capabilities:
raise PermissionError("capability_missing")
if not membership.is_current(*expected_scope, claims.actor_id):
raise PermissionError("membership_changed")
messages = log.read_after(*expected_scope, cursor=device_cursor)
return [
message
for message in messages
if message.conversation_id == requested_conversation
]
The final filter is not a substitute for a correctly partitioned log query. It is a containment check at the return boundary — useful because storage partitioning errors and authorization errors should not combine into a disclosure. Production code also needs bounded page sizes, a stable ordering rule, cancellation, retry policy, and an acknowledgement write that advances only after the client has durably applied the page.
Test the path as a state machine. Generate two tenants, two conversations per tenant, an agent and resident role, two device cursors, and tokens with one claim changed at a time. Assert that every cross-scope request is denied, that retrying a page never duplicates the applied message set, and that refreshing authority never changes either device cursor. Then inject disconnects between page read, client apply, and acknowledgement. These tests reveal more than a happy-path socket demo because they exercise the boundaries the design claims to provide.
If the chat adds audio or video, keep media-session authorization within the same tenant and conversation scope, but don't pretend the application token replaces the browser's realtime transport model. The WebRTC Recommendation defines the browser-facing peer-connection and data-channel model; application signaling and authorization still need explicit design. A media reconnect also must not advance the text-chat cursor. They are adjacent sessions, not one lifecycle.
Rejected option and its valid use case
Embedding the latest delivery cursor in the token was rejected because it gives an authorization artifact two owners and two clocks. Security wants expiration, revocation, and least privilege. Delivery wants per-device progress, replay, deduplication, and retention. Updating one because the other changed makes incident diagnosis needlessly ambiguous — was a gap caused by authority, cursor selection, log retention, or client application?
It can still be suitable for a disposable single-device status feed where missed items have no durable meaning, history is explicitly unavailable, and reconnect always begins from “now.” An online indicator in a shared property-management workspace may fit that narrower model if it is only advisory presence. Customer support messages do not. A maintenance promise, access instruction, or resident reply needs durable identity and replay semantics even when the surrounding presence indicator can tolerate loss.
The decision rule stays compact: isolate authority from progress, bind every read and connection to the full scope, and prove reconnect behavior with two devices and two tenants. Choose a simpler shared-session design when the deployment really has one trust boundary; choose online policy checks when immediate revocation dominates availability. Don't smuggle delivery state into a token just because both happen to appear during reconnect.
References
This article was originally published by DEV Community and written by jamesanderson3589.
Read original article on DEV Community