Most Phoenix Channel tutorials assume the socket carries an authenticated identity — a user token, a session cookie, something that connect/3 validates. That's the path of least resistance and it works for 95% of apps.
I ended up writing one where it was actively wrong.
The setup
I was building a zero-knowledge messaging app — two people share a secret and talk through a web channel, and the entire threat model hinges on the server not knowing who is talking to whom. If the socket had any durable identity, it would become a correlatable identifier. That identifier would exist in logs, in crash dumps, in whatever the BEAM's Process.info returns during a hot debug session.
I needed a socket the server literally could not identify.
The code
It turns out this is shorter than the normal path:
defmodule MyAppWeb.AnonSocket do
use Phoenix.Socket
channel "anon_room:*", MyAppWeb.AnonRoomChannel
@impl true
def connect(_params, socket, _connect_info), do: {:ok, socket}
@impl true
def id(_socket), do: nil
end
That's it. connect/3 accepts everyone. id/1 returns nil, which tells Phoenix.Socket there is no identifier to use for per-user broadcasts. The socket is equally anonymous to Phoenix and to anyone reading the code.
Where auth actually lives
All access control moves into Channel.join/3:
def join("anon_room:" <> room_hash, params, socket) do
%{"access_hash" => access_hash, "sender_hash" => sender_hash} = params
with :ok <- validate_hex(room_hash),
:ok <- validate_hex(access_hash),
:ok <- validate_hex(sender_hash),
{:ok, room} <- Rooms.get_active_room(room_hash),
:ok <- Rooms.verify_access(room_hash, access_hash) do
{:ok, assign(socket, room: room, sender_hash: sender_hash)}
else
_ -> {:error, %{reason: "unauthorized"}}
end
end
The three hashes are all SHA-256 hex strings computed client-side from a shared secret. The server verifies them against its database but never sees the secret itself.
What you give up
Phoenix's per-user utilities stop working because there is no "user":
-
Phoenix.PubSubkeyed bysocket.id— doesn't apply; there's nothing to key on. -
Presence tracking by user ID — works only at the topic level (who is in
anon_room:<hash>), not across topics for the same user. - Server-side rate limiting by identity — you have to fall back to IP-based rate limiting at the endpoint/plug layer, since the channel has no identity to throttle.
What you keep
- The channel itself still works normally — push, broadcast-to-topic,
handle_in, all of it. - You can still store assigns per-socket (
assign(socket, room: room)). You just can't share identity across sockets for the same user. - A smaller attack surface: a socket that never authenticates cannot leak authentication state.
The useful mental model
Normal Phoenix sockets are like a long-lived session: you log in once, then every channel inherits that identity. The sessionless variant is more like a capability-URL system — each channel join carries its own bearer credentials, and the socket is just a pipe.
For most apps this is unnecessary complication. For apps where the socket mustn't correlate activity across channels, it's the simpler mental model.
If you've got a use case where this pattern fits, I'd love to hear about it — I've only seen it come up a couple of times. (The app that drove this design: sTELgano, AGPL-3.0.)
Top comments (0)