Skip to content
LUIGI MICCA

Published

4 min read

A realtime channel is a door, and most apps leave it open

WebSocket topics feel like plumbing, so nobody asks who may listen. Then an anonymous client guesses "channel:3" and reads a private room, presence count included. What a channel actually promises, where the one decision belongs, and the four rules I now apply before a socket is upgraded.

I spent a week this autumn attacking a realtime relay I had built myself, and the first thing that fell was not clever. A client with no cookie opened a socket, asked for a topic called channel:3, and received every message posted there — plus a helpful __count event telling it how many real users were in the room. No exploit. No race. Just a name that was easy to guess and a server that never asked who was asking.

The relay was "correct" in every way the tests measured. It checked the browser's Origin, it capped message sizes, it closed misbehaving sockets with the right codes. What it did not do was the only thing that mattered: decide whether this socket, carrying this session, may hold this topic. The handshake was a door with a lock on the outside.

Where the decision has to live

A WebSocket has exactly one moment that looks like an HTTP request: the handshake. After the 101, there are no headers, no cookies re-sent, no middleware runs — just frames on a wire that stays open for hours. Whatever authorization exists must happen at the upgrade, with the session already verified, per topic the socket asks for. If the decision is anywhere else, it is nowhere.

This is also why "we authorize in the API routes" does not transfer. Your /api/channels/3/messages endpoint checks membership on every call because every call is a request. The socket subscribed once, weeks ago in socket-time, and the relay has been forwarding ever since. The two paths look like the same feature to the product and are completely different surfaces to an attacker.

The four rules I apply now

The topic name is not a secret. dm:ana:luigi is a routing key, and routing keys are enumerable. Scoping by unguessable id is a delay, not a wall. The rule has to be the session may hold this topic, evaluated against what the session proves — membership, ownership, role — not against how hard the string is to type.

Presence is data. The subscriber count felt like harmless UI sugar until it told an anonymous client how many people were in a private room. Anything the relay emits about a topic is part of that topic's confidentiality. If a topic is private, its count is too, and so is the fact that it exists.

Refuse by default in production, say so in development. A relay with no rule configured should refuse every subscription when deployed, loudly, and admit everything only in development, where the startup banner says so in a way you cannot miss. The dangerous configuration is the silent one: open by default, with a note in the docs that nobody reads before the incident.

Revocation has a latency, and it should be named. A socket authorized at its handshake keeps its topics until it closes. When you revoke a user — log out everywhere, remove them from a workspace — their open sockets are still subscribed. Decide what that means: cut the socket, push a "you are gone" event, or accept that the next handshake is the boundary. All three are defensible. Not deciding is not.

What good looks like from the application side

The shape I ended up with is small enough to read in one sitting. The application registers a function — given this request and this topic name, yes or no — and the relay calls it once per topic at the handshake, with the session the framework already verified. Publishing from the browser gets a second function or inherits the first. There is no annotation, no role matrix, no configuration file: a function, in the language the rest of the authorization is written in, next to the handler that creates the channel in the first place.

It took an afternoon to add and it closed the worst finding of a two-day stress test. The relay had been "done" for months.

The question to take to your own stack

Open the file that handles your WebSocket upgrade, or your SSE endpoint, or whatever your realtime layer is called. Find the line where a subscription is accepted. Now ask what, on that line, proves the subscriber may receive what follows — not what proves the browser is on your domain, not what proves the message is well-formed. If the answer is "the name of the topic", you have a door with the lock on the outside, and the people who find those do not file issues.