code-quality
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
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).
$ npx -y skills add j-morgan6/elixir-phoenix-guide --skill phoenix-authorization-patterns --agent claude-codeHow it fires
How this skill gets triggered: by you, by Claude, or both.
/phoenix-authorization-patternsContext 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).
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)."> Verified against Elixir 1.20, Phoenix 1.8, LiveView 1.2, Ecto 3.14 — October 2026
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.
---
`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()
endThe `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.
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.
---
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
endThe `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.
<!-- 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---
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
· Changelog A Claude Code plugin (with Codex support for the skills) that keeps Claude's Elixir and Phoenix code idiomatic and current.
Use when refactoring or reviewing Elixir code quality — compiler warnings and Credo first, then the plugin's on-demand…
Use when preparing releases or deployment config — mix phx.gen.release, runtime.exs vs compile-time config, release migrations,…
Use when writing Ecto changeset validations beyond cast/validate_required — custom and cross-field validations (e.g. end date…
Use when defining schemas, writing queries, or creating migrations — scoped context functions, Repo.transact, upserts, indexes,…
Use when migrating PostgreSQL tables that serve live traffic — index concurrently,…
Use when one form or operation manages parent and child records together — cast_assoc/cast_embed, inputs_for with sort/drop…