DEV Community

jaxmonroe3187
jaxmonroe3187

Posted on

Realtime Token Expiry vs Explicit Revocation: Ending Fintech Socket Access

A short-lived token limits how long stolen credentials remain useful; it does not end an already authenticated connection when a user clicks Log out. For a fintech device dashboard with an embedded support chat, use both controls: short token lifetime as the exposure bound, plus explicit session revocation that reaches every connection owned by that session. Define logout as complete only after that revocation barrier has crossed the fan-out path.

TL;DR: authenticate the socket with a token carrying a session identifier and expiry, register the connection under that session, and publish a revocation event on logout. Each gateway stops new delivery, closes matching sockets, and records its acknowledgement. If immediate logout matters, token expiry alone cannot provide it.

Why doesn't token expiry finish logout?

An expiry claim answers one narrow question: after what time must the credential be rejected? RFC 7519 defines exp as the time on or after which a JWT must not be accepted for processing. It does not prescribe how a server should terminate a connection accepted earlier.

That distinction gets lost in a support chat widget because the browser UI disappears immediately. The transport may remain open. A gateway that checks authentication only during the WebSocket upgrade can keep forwarding device alerts or chat messages until the connection closes for another reason. Even a gateway that checks expiry on every outbound event leaves a deliberate window between logout and expiry.

Five minutes is still five minutes.

That is the boundary.

If “Log out” means “the credential cannot start another connection after its expiry,” expiry may be enough. If it means “this session must stop receiving account data now,” the system needs a revocation signal and a way to enforce it on live connections. OWASP's WebSocket guidance likewise calls for handling session expiration and closing associated connections on logout.

Put a session boundary in the data flow

An HTTP authentication service issues a short-lived access token containing a stable, opaque session ID. A realtime gateway validates the token during connection setup and registers the socket by that ID. Device updates enter a broker, then fan out through multiple gateways to subscribed dashboards; support-chat messages use the same authorization boundary but a separate topic. Logout revokes the session in an authoritative store and publishes an invalidation event. Every gateway marks that session blocked before closing its sockets.

Keep user ID and session ID separate. Revoking one browser session should not necessarily sign the user out of a managed operations terminal, while a higher-risk “sign out everywhere” action can revoke every session belonging to the user.

interface Claims {
  sub: string;
  sid: string;
  exp: number;
}

interface LiveSocket {
  id: string;
  close(code: number, reason: string): void;
}

interface SessionStore {
  isRevoked(sessionId: string): Promise<boolean>;
  revoke(sessionId: string, revokedAtMs: number): Promise<void>;
}

interface RevocationBus {
  publish(event: RevocationEvent): Promise<void>;
  subscribe(handler: (event: RevocationEvent) => Promise<void>): void;
}

interface RevocationEvent {
  eventId: string;
  sessionId: string;
  revokedAtMs: number;
}

class ConnectionRegistry {
  private readonly sockets = new Map<string, Map<string, LiveSocket>>();
  private readonly blocked = new Set<string>();

  add(sessionId: string, socket: LiveSocket): boolean {
    if (this.blocked.has(sessionId)) return false;
    const group = this.sockets.get(sessionId) ?? new Map<string, LiveSocket>();
    group.set(socket.id, socket);
    this.sockets.set(sessionId, group);
    return true;
  }

  revoke(sessionId: string): void {
    this.blocked.add(sessionId);
    const group = this.sockets.get(sessionId);
    if (!group) return;
    for (const socket of group.values()) socket.close(1008, "session revoked");
    this.sockets.delete(sessionId);
  }

  mayDeliver(sessionId: string, now: number, exp: number): boolean {
    return !this.blocked.has(sessionId) && now < exp;
  }
}

const registry = new ConnectionRegistry();

async function acceptConnection(
  claims: Claims,
  socket: LiveSocket,
  sessions: SessionStore
): Promise<boolean> {
  if (Math.floor(Date.now() / 1000) >= claims.exp) return false;
  if (await sessions.isRevoked(claims.sid)) return false;
  return registry.add(claims.sid, socket);
}

async function logout(
  sessionId: string,
  sessions: SessionStore,
  bus: RevocationBus
): Promise<void> {
  const event = {
    eventId: crypto.randomUUID(),
    sessionId,
    revokedAtMs: Date.now()
  };
  await sessions.revoke(sessionId, event.revokedAtMs);
  await bus.publish(event);
}

bus.subscribe(async (event) => registry.revoke(event.sessionId));
Enter fullscreen mode Exit fullscreen mode

Two checks close a race. Connection setup consults authoritative revoked-session state, so a reconnect cannot slip through after logout. The local registry blocks delivery before it starts closing sockets, so a slow close handshake does not extend access. The handler is idempotent: duplicate revocation events have the same result.

Do not delete the blocked marker immediately. Keep revocation state until the latest possible expiry of credentials for that session, with a small clock-skew allowance selected by the operator. Otherwise, a delayed reconnect using the same still-valid token can become active after a gateway forgets the event.

Delivery guarantees belong to the control plane

Device telemetry and revocation do not deserve the same failure policy. Dropping an intermediate battery reading may be acceptable when a newer reading supersedes it. Dropping the event that ends access is not. Treat revocation as control-plane data with durable publication, retry, idempotent consumption, and observable progress.

The sharp edge is fan-out. One load balancer may route the dashboard socket to gateway A and the support-chat socket to gateway C. A best-effort in-memory broadcast closes only connections on processes that receive it. The logout endpoint can return success while another gateway continues delivery. Now add a reconnect: the browser sends the still-unexpired token to gateway B after gateway A handled logout, while gateway C retains the chat socket. Closing the initiating socket addresses none of those paths. The authoritative store must reject B, the control event must reach C, and A must keep the session blocked while its close frame is in flight. This is why counting closed sockets is weaker than tracking a session generation across every healthy fan-out worker.

Logout state Meaning Required behavior
Recorded Authoritative session state is revoked Reject every new connection
Propagated Every healthy gateway applied the event Stop delivery and close sockets
Bounded A gateway missed its acknowledgement deadline Drain it before it resumes fan-out

At-least-once delivery plus idempotent application handles duplicate events, but only if the system also detects consumers that never apply an event. A durable consumer cursor, per-gateway acknowledgement, or generation number checked before delivery can provide that evidence.

There is also an ordering race between a device update and revocation. A globally ordered stream would settle it, but forcing all telemetry through one order can add avoidable latency and operating cost. Use a narrower rule: revocation advances a per-session authorization generation, and each gateway compares that generation immediately before enqueueing an outbound message. This pays for ordering where confidentiality depends on it without pretending every sensor reading needs a total order.

Failure handling and verification

Checking a central session database before every fan-out event is clean on paper and expensive on the hot path. A dashboard receiving frequent status changes turns authentication storage into a latency dependency and multiplies reads with fan-out. A local blocked set keeps normal sends cheap, while the durable session store protects connection admission.

Recovery is the trade-off. After restart, a gateway must restore a trustworthy authorization generation or replay revocations from a durable cursor before accepting traffic. If it cannot catch up, keep it out of rotation. Fail closed for account data.

The browser should still clear its local token and close its socket on logout. That improves responsiveness, but it is not the security boundary: tabs crash, networks partition, and clients can be modified. Server-side enforcement carries the guarantee.

A unit test asserting close() was called proves little. Exercise two gateway instances, put dashboard traffic on one and support chat on the other, then revoke their shared session while device events are published.

Test the ugly path.

Duplicate the revocation. Delay it to one consumer. Restart a gateway after authoritative revocation but before acknowledgement, and attempt reconnection with the old token at each point.

In production, measure time from logout request to durable revocation, time to the last gateway acknowledgement, post-revocation delivery attempts blocked locally, and gateways behind the revocation cursor. Avoid reporting only socket-close duration; one closed client says nothing about another live connection. Logs should identify opaque session and connection IDs, never raw tokens or chat content.

Use token expiry to cap exposure when propagation fails. Use explicit revocation to implement logout. Commit authoritative session state first, durably publish invalidation next, and define completion through an acknowledgement policy. The runbook should verify connection indexing, admission checks, local delivery guards, idempotent handling, replay after restart, and removal of lagging gateways from rotation.

This costs a durable control event and a small amount of per-session state. It avoids a database lookup for every device update and gives “Log out” a meaning the system can prove.

References

Top comments (0)