DEV Community

Cover image for When your Phoenix socket has no identity at all (and why that was the right call)
Wycliff Ogembo
Wycliff Ogembo

Posted on

When your Phoenix socket has no identity at all (and why that was the right call)

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.PubSub keyed by socket.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)