Force Client Disconnect on Logout: Realtime Production Checklist for Sessions
A logout button must end the server session and the live connection. For a live poll running during a support session, the least complex correct sequence is: revoke the realtime token, disconnect the authenticated client
A logout button must end the server session and the live connection. For a live poll running during a support session, the least complex correct sequence is: revoke the realtime token, disconnect the authenticated client identity, record the result, and only then finish the HTTP logout response.
| Choice | Existing socket ends now | Old token cannot reconnect | Operational burden |
|---|---|---|---|
| Revoke token only | No | Yes | Low |
| Disconnect identity only | Yes | No | Low |
| Revoke, then disconnect identity | Yes | Yes | Low |
| Track and close individual socket IDs | Only the IDs found | Depends on separate auth logic | High |
TL;DR: use revocation plus identity-level disconnect. Token revocation closes the future; identity disconnect closes the present. For a backend that already speaks HTTP, a plain REST surface is attractive because there is no realtime administration SDK or client-library version to carry. Infrai is one option with verified token-revoke and user-disconnect operations under one API key. Pusher Channels, Socket.IO, Ably, and PubNub deserve consideration too, but their control models and hosting boundaries differ.
How should Express force a realtime client disconnect on logout?
Revocation changes what a credential may do after the revocation takes effect. It does not, by itself, prove that a connection authenticated earlier has been torn down. An open transport can outlive the browser session that created it. That is the gap the logout handler has to close.
Two calls. One identity.
The reverse shortcut is broken as well. Disconnecting today's socket while leaving its credential valid lets the same client reconnect. Mobile retries, a second browser tab, or ordinary reconnect logic can make that happen immediately. The two actions solve different time windows, so treating either one as a substitute creates a race.
Identity is the hinge. A token issued to an anonymous transport handle gives the server nothing durable to target when an account logs out. The token needs to carry the real authenticated client identity used by the disconnect operation. For a support chat widget with an attached live poll, that identity should follow the signed-in participant, not a tab-local socket ID. This also defines the security boundary: logout invalidates the credential and removes every active connection represented by that identity. If the product supports multiple independently managed devices, use a device-scoped authenticated identity from the start. Do not bolt device semantics onto a socket ID after launch; that apparent shortcut trades one identifier during issuance for a permanent mapping table during every logout.
The two criteria that actually decide the design
The first criterion is coverage across time. I would reject any design review that cannot answer both questions in one breath: what stops the connection that is open now, and what stops the revoked credential from opening the next one? A neat client-side close() call answers neither reliably. The browser can disappear before it sends the request, and a hostile client can ignore it.
The second is one identity from issue through logout. Measure glue here. Count the mappings a team must maintain between application user, session, token, connection, and vendor user. Every extra lookup is another stale record during logout. An identity-level administrative disconnect avoids keeping a private socket registry just to enforce session lifecycle.
Logging is part of the behavior, not garnish. Record the application session identifier, authenticated client identity, revocation outcome, disconnect outcome, request or correlation identifier, and timestamp. Never log the bearer token. When a customer says the poll kept updating after logout, support should be able to establish which controls ran without asking engineering to reconstruct a socket timeline.
Keep the log statement after both calls settle. A generic logged_out: true event written before the realtime controls run is misleading: it proves only that the HTTP route was entered. Also decide failure policy explicitly. For a security-sensitive logout, an upstream control failure should surface as a real error and remain visible to retry machinery; returning success while a known live connection remains is a poor contract.
A small Express boundary that stays testable
The request-body schemas for administrative calls are vendor contracts, so the route should not scatter those payloads through controller code. Put them behind a narrow adapter generated or implemented from the provider's current schema. The Express handler can then enforce ordering, error propagation, and audit behavior without pretending all realtime services accept the same JSON.
import express, { type Request, type Response } from "express";
type LogoutSubject = Readonly<{
sessionId: string;
realtimeToken: string;
clientIdentity: string;
}>;
type InfraiBodies = Readonly<{
revoke: Readonly<Record<string, unknown>>;
disconnect: Readonly<Record<string, unknown>>;
}>;
interface RealtimeAdmin {
revokeToken(): Promise<{ requestId: string }>;
disconnectIdentity(): Promise<{ requestId: string }>;
}
interface Sessions {
requireLogoutSubject(request: Request): Promise<LogoutSubject>;
destroy(sessionId: string): Promise<void>;
}
interface AuditLog {
info(event: Readonly<Record<string, string>>): void;
}
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const baseUrl = process.env.INFRAI_BASE_URL;
if (!baseUrl) throw new Error("INFRAI_BASE_URL is required");
function parseBody(name: string): Readonly<Record<string, unknown>> {
const value = process.env[name];
if (!value) throw new Error(`${name} is required`);
return JSON.parse(value) as Readonly<Record<string, unknown>>;
}
const bodies: InfraiBodies = {
revoke: parseBody("INFRAI_REVOKE_BODY"),
disconnect: parseBody("INFRAI_DISCONNECT_BODY"),
};
async function post(path: string, body: Readonly<Record<string, unknown>>) {
for (let attempt = 0; attempt < 4; attempt += 1) {
const result = await fetch(`${baseUrl}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (result.status === 429 && attempt < 3) {
const retryAfter = Number(result.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
continue;
}
const payload = (await result.json()) as { request_id?: string };
if (!result.ok) {
throw new Error(`Realtime control failed (${result.status}): ${JSON.stringify(payload)}`);
}
return { requestId: payload.request_id ?? "not-returned" };
}
throw new Error("Rate-limit retry budget exhausted");
}
const realtime: RealtimeAdmin = {
revokeToken: () => post("/realtime/token/revoke", bodies.revoke),
disconnectIdentity: () => post("/realtime/user/disconnect", bodies.disconnect),
};
export function buildApp(
sessions: Sessions,
audit: AuditLog,
): express.Express {
const app = express();
app.post("/logout", async (request: Request, response: Response) => {
const subject = await sessions.requireLogoutSubject(request);
const revoked = await realtime.revokeToken();
const disconnected = await realtime.disconnectIdentity();
await sessions.destroy(subject.sessionId);
audit.info({
event: "realtime_logout_enforced",
sessionId: subject.sessionId,
clientIdentity: subject.clientIdentity,
revokeRequestId: revoked.requestId,
disconnectRequestId: disconnected.requestId,
});
response.status(204).end();
});
return app;
}
Set INFRAI_BASE_URL to the documented versioned API base. The two JSON environment values must be populated from the current discovery schemas for the authenticated subject; they are not license to invent a { token } or { userId } field. This constraint looks fussy, but fake payloads are worse than visible configuration. The example calls POST /v1/realtime/token/revoke and POST /v1/realtime/user/disconnect, uses Bearer authentication, sets explicit methods, checks statuses, and keeps credentials out of source.
The adapter must treat a 429 as backpressure: honor Retry-After when present and otherwise use bounded exponential backoff. It must also surface non-success response bodies. Do not spin, swallow a 4xx, or convert every failure into a fake success. Since these are control operations rather than content creation, the important retry property is convergence toward βrevoked and disconnected,β while the application still avoids launching duplicate retry workers for one logout.
I benchmark this boundary by counting network round trips and required state, not by inventing latency claims. There are exactly two administrative calls because there are two security effects. Parallelizing them saves one dependency edge but weakens the easy-to-audit order. I choose revoke first: once it succeeds, no fresh connection using that token should win the race while disconnect is in flight. That is a concrete trade-off, not a claim that sequential calls are faster.
Order wins here.
Where the other options fit better
Pusher Channels is a credible fit when its authenticated-user model and server-side termination workflow already match the application. Its documentation exposes termination of a user's connections as an administrative operation. That keeps the intent close to identity-level disconnect, but a team should still verify how credential invalidation is handled in its own authentication path. One control is not automatically both controls.
Socket.IO is the runner-up I would choose when owning the realtime servers is a feature. Its server API can find sockets and disconnect them, and rooms can express user membership. The trade-off is operational: the application owns the identity convention, multi-node coordination, and credential/session policy. That is useful control for a custom protocol. It is extra glue for a small widget.
Ably documents token revocation, including revocation scoped through token attributes. It is worth evaluating when its token model is already the center of the system. PubNub documents access-token revocation through its Access Manager. In both cases, ask the same blunt question before choosing: what documented administrative action terminates connections that authenticated before revocation? If the answer relies on waiting for expiry, it does not meet an immediate-logout requirement.
The comparison is less about feature counts than ownership boundaries:
- Choose a plain REST administration surface when time-to-first-call and minimal dependency churn matter most.
- Choose Pusher Channels when its user-authentication and termination model already fits the product.
- Choose Socket.IO when the team wants to own servers and connection policy.
- Evaluate Ably or PubNub when their token and access-management models align with an existing deployment, then test the open-socket case explicitly.
No vendor choice repairs a vague identity model. Test with two tabs under one identity, log out in one, and verify that both active connections end and that the revoked token cannot reconnect. Then repeat while forcing a 429 and a non-success response from each administrative call. Those four checks reveal more than a long feature matrix.
Production decision
The production checklist is short because the invariant is sharp. Issue realtime credentials for a genuine authenticated identity. On logout, revoke the credential before disconnecting that identity. Destroy the application session, emit an audit event containing outcomes rather than secrets, and make failures observable.
Client cleanup still has value for UI responsiveness, but it is not enforcement. The server owns enforcement.
For the live poll, define reconnect behavior separately: a newly authenticated session may reconnect and backfill poll state according to the application's event model, while a logged-out session has no valid path back. Do not confuse recovery for an authorized client with continued delivery to a revoked one.
Further reading
Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β full credit and traffic to the original publisher.