Safeguard
Application Security

A WebSocket Is Authorised Once and Then Lives for Hours

The user is removed from the project, their role is downgraded, their session is revoked. The socket is still open and still receiving, because nothing re-evaluates a connection that was authorised in the past.

Priya Raman
Staff Security Engineer
6 min read

An HTTP request carries its authorisation with it, so every request is checked. A WebSocket is authorised once, at the handshake, and then stays open for hours while everything that made the decision valid changes underneath it.

The user is removed from the project. Their role is downgraded. Their session is revoked because a laptop was stolen. The socket is still open and still receiving messages, because nothing re-evaluates a connection that was authorised in the past.

This post is what long-lived connections change about authorisation. For whoever added realtime to a product that was request-response.

The handshake is not enough

Three properties make a socket different from a request.

It is authorised at a point in time. Whatever was true at the handshake governs the connection for its whole life, which may be hours. Every revocation you implement applies to new connections and not to existing ones.

It carries messages you did not route. After the handshake the client sends frames, and those frames usually select a channel, a room or a subscription. That selection is an authorisation decision on every message, made by code that often assumes the connection was already checked.

Authentication at the handshake is awkward. Browsers do not let you set headers on a WebSocket upgrade, so implementations fall back to a token in the query string, which lands in logs and referrers, or to the cookie, which makes the handshake cross-site requestable.

Check the origin, because the cookie will be sent

A WebSocket upgrade from another site is not blocked by the same-origin policy, and the browser attaches cookies. If your handshake authenticates from a cookie and does not validate Origin, any page a logged-in user visits can open an authenticated socket to your server and read whatever it pushes.

This is cross-site request forgery with a persistent channel, and it is the one failure here with no partial version: either the check is there or the whole connection is available to anyone's page.

if (!ALLOWED_ORIGINS.has(request.headers.origin)) {
  socket.destroy();        // refuse the upgrade
}

Prefer a token supplied in the first message after connecting, rather than the cookie, so the connection is not authenticated by ambient credentials at all. If you must use a query string token, make it short-lived and single use, and strip query strings from your access logs.

Authorise the subscription, not just the connection

The message that matters is usually the one that says which stream to join:

{ "type": "subscribe", "channel": "project:4812:events" }

The check on that message must answer whether this viewer may read that specific channel, using the identity established at the handshake, every time. The common bug is a server that validates the channel name's shape but not the caller's access to it, so subscribing to another tenant's channel is a matter of knowing or guessing the identifier.

Identifiers in channel names should be unguessable for the same reason, but that is a second layer, not the control.

Re-evaluate during the connection's life

This is the part that needs deliberate design, because nothing does it for you.

Expire the connection. Give sockets a maximum lifetime, an hour or two, and require a reconnect that re-authorises. This bounds every staleness problem at once and costs a reconnection your client already handles for network reasons.

Re-check on a schedule. Periodically confirm the session is still valid and the subscriptions are still permitted, and close what is not.

Push revocation to open connections. When access is removed, look up the affected sockets and close them. This is the only mechanism that acts immediately, and it needs a registry of which connections hold which subscriptions, which is worth building if your product carries sensitive realtime data.

Without at least one of these, your revocation story ends at "new connections will be refused", and you should know that is where it ends.

The fan-out is where data crosses tenants

A realtime system holds a mapping from event to recipients, and a bug there sends one customer's event to another's socket. It is the same class as a missing tenant filter, in a component usually written for throughput rather than isolation.

Two things help. Derive the recipient list from the authorisation model rather than from a cached room membership that can drift, and include the tenant in the event so the sending side can assert it matches the socket's tenant before writing. The second is a cheap last check and it catches the mapping bug.

The resource side

An open connection holds memory and a file descriptor for as long as it lives, and connections are cheap to open. Cap them per user and per address, limit message rate and size per connection, and make sure an authenticated client cannot subscribe to unbounded channels. A client that subscribes to ten thousand channels is a denial of service using a supported feature.

Check yours

# Origin enforced on the upgrade?
curl -i -N -m 8 https://app.example.com/ws \
  -H "Origin: https://evil.example" \
  -H "Cookie: session=$SESSION" \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" \
  | head -3
# want a refusal, not 101 Switching Protocols

Then, with a client: subscribe to a channel belonging to another tenant. Revoke your own access in another window and see whether messages keep arriving. Those two tests take five minutes and cover the failures that matter.

The concession

Closing sockets on revocation requires a connection registry and a way to reach the right node in a multi-instance deployment, which is real infrastructure for something that happens rarely. For a product where the realtime channel carries presence indicators and typing notifications, it is over-engineering.

Scale it to what flows through the socket. Low-sensitivity signals: a bounded connection lifetime is enough, and an hour of staleness is acceptable. Anything carrying customer records, financial data or administrative events: build the registry, because "they keep receiving until they reconnect" is not an answer you want to give.

The implication

Request-response gave you re-authorisation for free, on every request, and a persistent connection takes it away without announcing that it has.

The question worth asking of your own implementation is simple: if you revoke someone's access right now, how long do they keep receiving data. If nobody knows, the answer is until they close the tab.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.