Skip to content
Development
Skill

/phoenix-channels-essentials

Use when building WebSocket features with Phoenix Channels — socket auth and tokens, join authorization, handle_in/push/broadcast, intercept/handle_out, Presence. Not for LiveView updates (use phoenix-pubsub-patterns).

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill phoenix-channels-essentials --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/phoenix-channels-essentials

Context preview

The summary Claude sees to decide when to auto-load this skill.

Use when building WebSocket features with Phoenix Channels — socket auth and tokens, join authorization, handle_in/push/broadcast, intercept/handle_out, Presence. Not for LiveView updates (use phoenix-pubsub-patterns).

SKILL.md

phoenix-channels-essentials.SKILL.md
name: phoenix-channels-essentials
description: "Use when building WebSocket features with Phoenix Channels — socket auth and tokens, join authorization, handle_in/push/broadcast, intercept/handle_out, Presence. Not for LiveView updates (use phoenix-pubsub-patterns)."

Phoenix Channels Essentials

> Verified against Elixir 1.20, Phoenix 1.8, LiveView 1.2 — October 2026

For non-LiveView real-time features: mobile clients, SPAs, external APIs, inter-service communication.

RULES — Follow these with no exceptions

1. **Authenticate in `connect/3`; return `:error` otherwise.** Same-origin browser clients can reuse the phx.gen.auth session through `connect_info: [session: @session_options]`; other clients use a `Phoenix.Token` verified with an explicit `max_age` (keep it short, e.g. `86_400`; it can't be revoked). 2. **Make sockets disconnectable on logout.** `id/1` returns a topic you can broadcast `"disconnect"` to. With session auth, return the session's `live_socket_id` and the generated `log_out_user/1` already does it. 3. **Authorize in `join/3` through the scope** — load the topic's resource with `socket.assigns.current_scope` and return `{:error, %{reason: "unauthorized"}}` if that fails. The topic string is client input. 4. **Keep `check_origin` on in production.** Turning it off for a cookie-authenticated socket leaves the CSRF check as the only guard against cross-site WebSocket hijacking; Phoenix raises if both `check_origin` and `check_csrf` are `false`. 5. **Keep channel modules thin** — delegate to context functions that take the scope. 6. **Reply when the client needs the outcome** (`{:reply, {:ok, payload} | {:error, payload}, socket}`). `{:noreply, socket}` is fine for fire-and-forget events (typing indicators, the generator's `"shout"`). 7. **Use `intercept`/`handle_out` only to filter or customize a broadcast per subscriber.** An intercepted event is encoded once per subscriber instead of once per topic. 8. **Use `Phoenix.Presence` for who's online**, in channels and LiveViews. In channel tests, `assert_push "presence_state", _` before you read `Presence.list/1`.

---

Endpoint Configuration

# lib/my_app_web/endpoint.ex
socket "/socket", MyAppWeb.UserSocket,
  websocket: [connect_info: [session: @session_options]],
  longpoll: false

`@session_options` is the attribute the generated endpoint already defines for `/live`. `check_origin` defaults to `true` (the request's `origin` must match the endpoint's `url: [host: ...]`); generated `dev.exs` turns it off. If you serve several hosts, list them in prod config:

# config/runtime.exs
config :my_app, MyAppWeb.Endpoint,
  check_origin: ["https://example.com", "https://www.example.com"]

Native mobile clients don't send `origin`, so the check doesn't apply to them; they must use token auth.

---

Socket Authentication

Browser clients: reuse the session (revocable)

defmodule MyAppWeb.UserSocket do
  use Phoenix.Socket

  alias MyApp.Accounts
  alias MyApp.Accounts.Scope

  channel "room:*", MyAppWeb.RoomChannel

  @impl true
  def connect(_params, socket, %{session: %{"user_token" => token} = session}) do
    case Accounts.get_user_by_session_token(token) do
      {user, _token_inserted_at} ->
        {:ok,
         socket
         |> assign(:current_scope, Scope.for_user(user))
         |> assign(:live_socket_id, session["live_socket_id"])}

      nil ->
        :error
    end
  end

  # No session cookie, failed CSRF check (session is nil), or a guest.
  def connect(_params, _socket, _connect_info), do: :error

  # Same id as the LiveView socket: UserAuth.log_out_user/1 broadcasts
  # "disconnect" to it, and the deleted session token blocks the reconnect.
  @impl true
  def id(socket), do: socket.assigns.live_socket_id
end
// assets/js/user_socket.js: the session is only read if _csrf_token is sent
import {Socket} from "phoenix"

const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
const socket = new Socket("/socket", {params: {_csrf_token: csrfToken}})
socket.connect()
export default socket

Other clients: Phoenix.Token

Only sign a token for a logged-in user. A plug in the `:browser` pipeline (after `:fetch_current_scope_for_user`) keeps guests working:

# lib/my_app_web/user_auth.ex
def put_socket_token(conn, _opts) do
  case conn.assigns[:current_scope] do
    %{user: %{id: user_id}} ->
      assign(conn, :socket_token, Phoenix.Token.sign(conn, "user socket", user_id))

    _guest ->
      assign(conn, :socket_token, nil)
  end
end
<%!-- root.html.heex, in <head> --%>
<meta :if={@socket_token} name="socket-token" content={@socket_token} />
const token = document.querySelector("meta[name='socket-token']")?.content
if (token) {
  const socket = new Socket("/socket", {authToken: token})
  socket.connect()
}
# endpoint.ex: auth_token: true sends the token in a header, not the URL
socket "/socket", MyAppWeb.UserSocket, websocket: true, longpoll: false, auth_token: true

# user_socket.ex
@impl true
def connect(_params, socket, %{auth_token: token}) when is_binary(token) do
  # Always pass max_age (1 day here). Don't extend it: the token can't be revoked.
  with {:ok, user_id} <- Phoenix.Token.verify(socket, "user socket", token, max_age: 86_400),
       %User{} = user <- Accounts.get_user(user_id) do
    {:ok, assign(socket, :current_scope, Scope.for_user(user))}
  else
    _ -> :error
  end
end

def connect(_params, _socket, _connect_info), do: :error

@impl true
def id(socket), do: "user_socket:#{socket.assigns.current_scope.user.id}"

`Accounts.get_user/1` is `Repo.get(User, id)`, which you add; the generator only has `get_user!/1`. A mobile client sends the token from your API login response instead of a meta tag.

On logout, close that user's open sockets. Add this to the generated `log_out_user/1` before the session is renewed:

Read more
Ships withelixir-phoenix-guide

· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.

Get the whole plugin

Other skills on elixir-phoenix-guide.