DEV Community

MaximilianNilsson7568
MaximilianNilsson7568

Posted on

Realtime Client Still Connected After Logout Token Revocation Explained with WebRTC

Treat logout as a distributed revocation event, not as a browser navigation event. A property manager's support chat and live device-status stream must stop accepting and delivering account-scoped data when the authorization session ends, even if the WebRTC data channel, WebSocket, or server-sent event connection still looks healthy.

TL;DR: bind every live connection to a server-side session identifier, publish one revocation event on logout, reject subsequent messages at the authorization boundary, and close every connection registered to that session. The close is cleanup; the authorization check is the security control. For fan-out, prefer at-least-once delivery of an idempotent revocation command, because losing one disconnect is worse than processing the same disconnect twice.

Why can a realtime client remain connected after logout?

Authentication state and transport state have different lifetimes. Deleting a cookie, revoking an OAuth token, or ending an application session does not inherently call RTCPeerConnection.close(), send a WebSocket Close frame, or invoke EventSource.close() in every tab. RFC 7009 also allows a revocation service to respond successfully even when the token is already invalid, and notes that propagation delay can exist across servers. A green connection indicator therefore proves reachability, not permission.

That distinction is the trap.

WebRTC makes this separation especially easy to miss. After signaling has established a peer connection, application data can travel over an RTCDataChannel; the WebRTC specification defines an explicit close operation for the peer connection. Revoking the credential originally used at signaling time is not described as an automatic close mechanism for that established channel.

The visible symptom is confusing but precise: the operator logs out, the dashboard route changes, yet another tab still receives a building sensor transition or a support-chat message. The unsafe failure is not merely a lingering socket. It is delivery after the session's authorization epoch has ended.

Decision record and failure boundaries

The architecture needs four invariants.

  1. Each connection is registered under an opaque server-side session ID, a user ID, and a monotonically increasing authorization epoch. Do not use the raw access token as the registry key or place it in logs.
  2. Logout changes authoritative session state before it attempts best-effort transport cleanup.
  3. Every inbound message and every account-scoped outbound fan-out checks that the connection epoch is still current. A connection that misses the close event still cannot read or write protected data.
  4. Revocation commands are idempotent and carry a stable event ID. Duplicate delivery may repeat a close attempt, but it cannot restore access or produce a second business event.

There are several failure boundaries, and naming them matters. The browser can disappear before sending logout. The API can commit revocation and fail before publishing the disconnect command. A broker consumer can receive the command twice. A regional subscriber can be partitioned. A process can restart with an in-memory connection registry. A peer can ignore an application-level “please close” message. None of those failures may turn a revoked epoch back into an authorized one. Consider the narrow commit-then-publish gap: the session row says revoked, the API process stops before its publish call, and a connection node never hears the disconnect request. The correct result is an ugly but safe intermediate state, where the socket remains physically open but every attempted chat send and device-status delivery fails the epoch check. Durability belongs at that state transition. Store the session revocation and an outbox record in the same database transaction, then let a relay publish the outbox record. This does not create exactly-once delivery; it supports at-least-once delivery when consumers deduplicate by event ID. If session state and the outbox live in different stores without a shared transaction, document the interval in which one can succeed without the other.

Prefer safety over a tidy connection graph.

Comparing revocation at fan-out

Strategy Delivery behavior Failure mode Appropriate boundary
Browser-only cleanup Best effort from one document Crashed, suspended, or second tabs keep their transports Cosmetic local cleanup after server revocation
Short token lifetime alone Access expires on a clock An established channel may outlive the credential check; expiry also delays enforcement Limiting credential exposure, not immediate logout
Ephemeral disconnect broadcast At-most-once in practice when no durable replay exists A partitioned or restarting subscriber misses the command Non-sensitive presence indicators
Durable idempotent revocation event plus epoch checks At-least-once command delivery with authorization enforced independently Duplicate closes and delayed cleanup, both harmless when handlers are idempotent Account-scoped chat and device-status fan-out

The last row is the decision. Exactly-once transport claims would not remove the need for idempotency: processes can fail after applying a command and before acknowledging it. The useful contract is narrower and testable: a revoked session cannot pass an authorization check, and each connected node will eventually observe a replayable request to close matching transports.

For a property operations dashboard, fan-out deserves its own check. A sensor update may be valid for three logged-in building operators and invalid for the operator who just logged out. Authorize the recipient set against current session epochs before enqueueing or writing to each connection; do not authorize the event once and assume every subscriber remains eligible throughout the broadcast.

Critical path in Python

The following code is deliberately an interface-level sketch. The transaction, durable outbox, session registry, and transport adapters can use different implementations, but their ordering is the contract.

from dataclasses import dataclass
from typing import Protocol
from uuid import UUID


@dataclass(frozen=True)
class RevokedSession:
    event_id: UUID
    session_id: UUID
    revoked_epoch: int


class SessionStore(Protocol):
    def revoke_and_enqueue(self, session_id: UUID) -> RevokedSession: ...

    def is_current(self, session_id: UUID, epoch: int) -> bool: ...


class ConnectionRegistry(Protocol):
    def close_session(self, session_id: UUID, reason: str) -> None: ...


class DeduplicationStore(Protocol):
    def claim(self, event_id: UUID) -> bool: ...


def logout(session_id: UUID, sessions: SessionStore) -> RevokedSession:
    # One transaction revokes the session and appends its outbox event.
    return sessions.revoke_and_enqueue(session_id)


def consume_revocation(
    event: RevokedSession,
    seen: DeduplicationStore,
    connections: ConnectionRegistry,
) -> None:
    if not seen.claim(event.event_id):
        return
    connections.close_session(event.session_id, reason="session revoked")


def may_deliver(
    session_id: UUID,
    connection_epoch: int,
    sessions: SessionStore,
) -> bool:
    return sessions.is_current(session_id, connection_epoch)
Enter fullscreen mode Exit fullscreen mode

close_session should locate every local WebRTC, WebSocket, and SSE transport registered to the session. For WebRTC 1.0, close the data channels and then the peer connection; for WebSocket, initiate the RFC 6455 protocol close handshake; for SSE in a browser, call EventSource.close() during local cleanup while the server independently terminates its response stream. The adapter should tolerate “already closed,” because duplicates and races are normal here.

Do not trust this design from a happy-path test. Open two tabs under one session, establish the chat and device stream, revoke from a third request, and assert that both tabs lose authorization. Then pause one subscriber, revoke, restart it, and verify that durable replay closes the stale connection. Repeat the same event ID twice. Finally, force the outbox relay to fail after publishing but before marking its record sent; the consumer must handle the duplicate without emitting duplicate chat or device events. The trade-off is explicit: this test accepts duplicate control delivery so protected payload delivery can stop even when one consumer acknowledgment is lost.

Observe three separate timings: revocation commit latency, time until each connection node consumes the command, and time until its transports close. Count outbound authorization denials after revocation as a security signal. Never attach access tokens, chat text, tenant addresses, or device payloads to those metrics.

The rejected shortcut and where it still fits

I reject “delete the browser token and wait for expiry” for account-scoped support chat and property-device status. It puts enforcement in the least reliable participant, leaves other tabs untouched, and confuses credential lifetime with transport lifetime. Making tokens very short-lived narrows some exposure, but it also increases refresh traffic and does not prove that an already established peer channel rechecks the token.

Browser-only cleanup still has a valid job. It can clear UI state immediately, call the local close APIs, and prevent a stale “connected” badge while the server-side revocation travels. An ephemeral broadcast can also be adequate for a typing indicator or online-presence hint where losing an update carries no authorization consequence.

The operational decision is blunt: classify the stream by the harm of post-logout delivery. If it contains tenant conversation, access-control state, or building telemetry, persist the revocation transition, replay its disconnect command, and enforce the epoch on the data path. If it is disposable presence metadata, best-effort delivery may be the better complexity trade-off.

References

Top comments (0)