Skip to content
Development
Skill

/phoenix-authorization-patterns

Use when deciding who may do what to which record — scoping context calls with %Scope{}, ownership checks, policy modules, roles, mass-assignment safety in LiveViews and controllers. Not for login/session plumbing (use phoenix-liveview-auth).

BOOST
From plugin
elixir-phoenix-guide
16924 skills3 hooks
Install
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill phoenix-authorization-patterns --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-authorization-patterns

Context preview

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

Use when deciding who may do what to which record — scoping context calls with %Scope{}, ownership checks, policy modules, roles, mass-assignment safety in LiveViews and controllers. Not for login/session plumbing (use phoenix-liveview-auth).

SKILL.md

phoenix-authorization-patterns.SKILL.md
name: phoenix-authorization-patterns
description: "Use when deciding who may do what to which record — scoping context calls with %Scope{}, ownership checks, policy modules, roles, mass-assignment safety in LiveViews and controllers. Not for login/session plumbing (use phoenix-liveview-auth)."

Phoenix Authorization Patterns

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

RULES — Follow these with no exceptions

1. **Pass `current_scope` as the first argument to every context call** — `Blog.get_post!(socket.assigns.current_scope, id)`, never `Blog.get_post!(id)` or `Repo.get!(Post, id)` with an id from params. Loading by a client id without the scope is an IDOR. 2. **Scope the query, not just the check** — `get_post!(%Scope{} = scope, id)` is `Repo.get_by!(Post, id: id, user_id: scope.user.id)`: another user's record raises `Ecto.NoResultsError` (a 404), so existence isn't leaked and no separate check can be forgotten. 3. **Authorize inside the context function** — mutations re-check ownership or call the policy (`true = post.user_id == scope.user.id`, as the 1.8 generator does), so every caller (LiveView, controller, job) is covered. 4. **Authorize in `handle_params/3` as well as `mount/3`** — `push_patch` re-runs `handle_params` but not `mount`; load any record whose id arrives in the URL through the scope where the params arrive. 5. **Authorize every `handle_event/3` on the server** — hiding a button is UX, not security; events can be crafted with any params. 6. **Never `cast` ownership or privileged fields** — `:user_id`, `:org_id`, `:role`, `:confirmed_at`, `:deleted_at`. Set them with `put_change/3` or `Ecto.build_assoc/3` from the scope, as the generator's `changeset(post, attrs, scope)` does. 7. **Policies take `%Scope{}`, not `%User{}`** — the scope is what the web layer has, and it can carry org and role. 8. **Put roles and orgs on the `Scope` struct** — extend the generated `Scope` (its moduledoc says to) and fill it once in an `on_mount` hook / plug, instead of threading extra arguments. 9. **Scope PubSub topics too** — subscribe and broadcast on `"user:#{scope.user.id}:posts"` (the generator's `subscribe_posts(scope)`), never a global topic. 10. **Test the unauthorized path** — another user's id must raise `Ecto.NoResultsError` (or return `{:error, :unauthorized}`); every mutating event needs a test proving that.

---

Scoped Contexts (the 1.8 generator pattern)

`mix phx.gen.live Blog Post posts title:string body:text` in an app with `phx.gen.auth` generates scoped contexts (the generated module, with its broadcasts, is in the `phoenix-pubsub-patterns` skill). Follow the shape for hand-written contexts. The two authorization points:

# Reads: the owner filter is part of the query
def get_post!(%Scope{} = scope, id) do
  Repo.get_by!(Post, id: id, user_id: scope.user.id)
end

# Writes: re-check ownership of the struct the caller passes in
def update_post(%Scope{} = scope, %Post{} = post, attrs) do
  true = post.user_id == scope.user.id

  post
  |> Post.changeset(attrs, scope)
  |> Repo.update()
end

The `true = ...` match crashes on a mismatch — correct when the caller should already have loaded the post through `get_post!/2`; a crash there means a bug, not a user error.

A repo-level `prepare_query/3` guard for multi-tenant `org_id` filtering is in the `ecto-queries-performance` skill.

Mass assignment: ownership comes from the scope

The generator's changeset never casts the owner; it sets it from the scope:

post
|> cast(attrs, [:title, :body])
|> put_change(:user_id, user_scope.user.id)

For the bad/good pair and foreign keys the user chooses (`folder_id`), see the `ecto-changeset-patterns` skill.

---

LiveViews

defmodule MyAppWeb.PostLive.Index do
  use MyAppWeb, :live_view

  alias MyApp.Blog

  @impl true
  def render(assigns) do
    ~H"""
    <Layouts.app flash={@flash} current_scope={@current_scope}>
      <div id="posts" phx-update="stream">
        <div :for={{id, post} <- @streams.posts} id={id}>
          {post.title}
          <.link
            phx-click={JS.push("delete", value: %{id: post.id}) |> hide("##{id}")}
            data-confirm="Are you sure?"
          >
            Delete
          </.link>
        </div>
      </div>
    </Layouts.app>
    """
  end

  @impl true
  def mount(_params, _session, socket) do
    {:ok, stream(socket, :posts, Blog.list_posts(socket.assigns.current_scope))}
  end

  @impl true
  def handle_event("delete", %{"id" => id}, socket) do
    scope = socket.assigns.current_scope
    post = Blog.get_post!(scope, id)
    {:ok, _} = Blog.delete_post(scope, post)

    {:noreply, stream_delete(socket, :posts, post)}
  end
end

The `id` comes from the client, but `get_post!/2` only finds the caller's own posts. `data-confirm` is a UX guard against misclicks, nothing more.

handle_params: patches skip mount

<!-- epg:bad -->

# Bad — mount checks, but a push_patch to /posts/OTHER_ID/edit only runs handle_params
def handle_params(%{"id" => id}, _uri, socket) do
  {:noreply, assign(socket, :post, Repo.get!(Post, id))}
end
@impl true
def handle_params(%{"id" => id}, _uri, socket) do
  post = Blog.get_post!(socket.assigns.current_scope, id)
  {:noreply, assign(socket, post: post, form: to_form(Blog.change_post(socket.assigns.current_scope, post)))}
end

def handle_params(_params, _uri, socket) do
  {:noreply, assign(socket, post: nil, form: nil)}
end

---

Roles and Orgs on the Scope

Extend the generated struct with `org` and `org_role` (plus a `put_org/3` builder) and keep `for_user/1`, which still returns `nil` for guests. Fill those fields once, after authentication, from a membership lookup through the current scope (`Orgs.get_membership(scope, slug)`), never from a param or a cast field. Do it in both an `on_mount` hook (websocket mount and live navigation) and a plug (HTTP render and controlle

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.