Authenticating WebSockets Behind a BFF
A WebSocket is authenticated once and then trusted for as long as it stays open, which is a very different model from the per-request checks the rest of your application runs. This page is part of the BFF pattern for SPAs guide and covers how to authenticate the upgrade, keep the connection honest afterwards, and close it when access ends.
Authenticate at the Upgrade, Not in a Frame
The upgrade request is an ordinary HTTP request: it carries cookies, it has an Origin header, and it can be rejected with a normal status code. That makes it the right place — and the only clean place — to establish identity.
server.on("upgrade", async (req, socket, head) => {
// Cookies are present on the upgrade request — resolve the session as usual.
const session = await sessions.fromCookie(req.headers.cookie);
const origin = req.headers.origin;
if (!session || !ALLOWED_ORIGINS.has(origin ?? "")) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
return socket.destroy();
}
wss.handleUpgrade(req, socket, head, (ws) => {
ws.userId = session.userId; // identity fixed at open time
ws.sessionId = session.id;
ws.sessionVersion = session.version; // used for periodic revalidation
wss.emit("connection", ws, req);
});
});
The Origin check is not optional. WebSocket upgrades are not subject to the same-origin policy in the way fetch is, and the browser will happily let a page on any site open a socket to your server with the user’s cookies attached — the WebSocket equivalent of cross-site request forgery. Validate Origin against an exact allowlist and reject anything else.
Keeping a Long Connection Honest
Identity established at open time goes stale. The user logs out on another tab, an administrator disables the account, a role is revoked — and the socket keeps streaming, because nothing has asked it a question since it opened.
Re-validate on a timer. Every thirty to sixty seconds, compare the connection’s stored session version against the current one and close the socket if it has changed or the session has gone. That is one cheap lookup per connection per interval, and it converts an indefinite trust window into a bounded one.
Pair the timer with an explicit close on logout. When sign-out-everywhere runs, look up the user’s open connections and close them immediately — the timer is the backstop, not the primary mechanism.
Authorizing Messages, Not Just the Connection
An open socket is a channel, and the messages on it are requests. Subscribing to a topic, joining a room, or requesting a stream of a particular resource are all authorization decisions, and the fact that the connection is authenticated says nothing about whether this user may see that resource.
Check on subscribe, and re-check when the underlying grant could have changed. Where a socket carries data for several resources, keep the authorization decision per subscription rather than per connection, so revoking access to one project does not require closing the whole channel — and so a bug in one subscription cannot leak another’s data.
Rate-limit inbound messages per connection. A socket is a cheap way for a client to make many requests, and without a limit a single misbehaving page can generate far more load than an equivalent HTTP client.
Reconnection Behaviour
Sockets close constantly in the real world — a phone changes network, a laptop sleeps, a proxy times out an idle connection — so reconnection logic is not an edge case but the normal path, and it is where authentication bugs hide.
Give the client exponential backoff with jitter, and a cap. Without jitter, a server restart produces a synchronised reconnect storm from every client at once; without a cap, a client that will never succeed keeps trying forever. Both are cheap to add and expensive to discover in production.
Make the close reason actionable. Use distinct close codes for “transport problem, try again”, “your session ended, log in”, and “you are not permitted to subscribe to this”, so the client can respond correctly instead of blindly reconnecting. A revoked session that produces a reconnect loop is both useless and a self-inflicted load test.
Re-authenticate on every reconnect rather than restoring a previous identity from client state. The upgrade is cheap, the cookie is already there, and a socket that resumes a session which no longer exists is exactly the gap the revalidation timer was added to close.
Scaling Across Instances
A socket is bound to one process, which makes revocation and fan-out interesting once you run more than one instance. Two decisions cover it.
Keep a registry of open connections keyed by user, in shared storage rather than in process memory, so any instance can answer “does this user have live connections, and where?”. That registry is what makes an immediate close on logout possible; without it, revocation degrades to whatever your revalidation timer happens to be.
Use a publish-subscribe channel for cross-instance events — a logout, a permission change, a broadcast to a room — so the instance holding a connection is told rather than polled. Keep the payload small and non-sensitive: an instruction to re-check, not the data itself, so a misrouted message cannot leak anything.
Keep the registry entries short-lived and refreshed by a heartbeat rather than deleted only on a clean close, because processes crash without cleaning up and a registry full of connections that no longer exist makes revocation look successful when it did nothing. A heartbeat with a timeout slightly longer than its interval keeps the picture accurate for a modest write cost.
Finally, plan for connection counts. Long-lived sockets change the shape of your capacity planning entirely: a service that handles thousands of requests per second may still struggle with tens of thousands of idle connections, because the constraint is memory and file descriptors rather than throughput. Measure memory per connection under realistic subscription counts, set an explicit per-instance limit, and decide in advance what happens when it is reached — refusing an upgrade with a clear status is far better than accepting one the instance cannot serve. Deployments also need thought: rolling a fleet disconnects every socket it holds, so stagger instances and rely on the client’s backoff rather than restarting everything at once.
Frequently Asked Questions
Why not pass a token in the WebSocket URL?
Because URLs leak. They appear in server access logs, proxy logs, browser history and any error-reporting payload that captures the request, and unlike a header they are frequently recorded by infrastructure you do not control. Behind a BFF you do not need it: the upgrade request carries the session cookie already. Where a cookie genuinely is not available, use a short-lived single-use ticket exchanged over HTTPS and redeemed once at upgrade.
Do WebSockets need CSRF protection?
They need the equivalent: an Origin check at upgrade. The browser sends cookies on a cross-site WebSocket upgrade and does not apply the protections that make cross-site fetch safe, so any page could otherwise open an authenticated socket to your server. Validate Origin against an exact allowlist and reject anything unrecognised.
How often should the connection revalidate?
Every 30 to 60 seconds for most applications — cheap enough at a lookup per connection, and short enough that a revocation takes effect promptly. For high-value streams, shorten it or subscribe to a revocation event so the close is immediate. Whatever you choose, document it, because it is the real revocation latency for anything delivered over the socket.
What should the client do when the socket closes?
Distinguish a transport failure from an authentication failure. On a network close, reconnect with backoff. On a close carrying an authentication reason, stop reconnecting and send the user to log in — otherwise a revoked session produces a reconnect loop that hammers your server and never succeeds. Use distinct close codes so the client can tell them apart.
Should the BFF proxy the WebSocket or terminate it?
Terminate it at the BFF where you can, because that is where the session lives and where the identity decision belongs. If a downstream service must own the socket, have the BFF authenticate the upgrade and hand off with an internal token that names the user and expires quickly — never by forwarding the browser’s cookie, which would put session material somewhere it does not belong.
Treat the socket layer as part of the authentication surface rather than as a transport detail, and review it whenever the session model changes. The two are coupled in ways that are easy to forget precisely because the socket keeps working long after the decision that authorised it.
Related
- Implementing the BFF pattern for SPAs — where the session that authenticates the upgrade comes from.
- Implementing sign out everywhere across devices — closing live connections as part of revocation.
- Managing session state in Redis — the version counter used for revalidation.