From elixir-phoenix-guide
Provides Phoenix authorization patterns for LiveViews and controllers: server-side ownership checks in handle_event, policy modules, scoped queries to prevent IDOR.
npx claudepluginhub j-morgan6/elixir-phoenix-guide --plugin elixir-phoenix-guideThis skill uses the workspace's default tool permissions.
1. **Always authorize on the server in event handlers** — UI-only checks (hiding buttons) are not security; always verify in `handle_event/3`
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Checks Next.js compilation errors using a running Turbopack dev server after code edits. Fixes actionable issues before reporting complete. Replaces `next build`.
handle_event/3current_scope.user.id against the resource's user_id — never trust client-sent user IDsdata-confirm attribute for destructive UI actions — client-side confirmation before server round-triphandle_event that mutates data needs an authz test proving unauthorized access is rejectedwhere(user_id: ^user_id) prevents IDOR vulnerabilitiesUI checks prevent accidental clicks. Server checks prevent attacks. You need both.
defmodule MyAppWeb.PostLive.Show do
use MyAppWeb, :live_view
@impl true
def mount(%{"id" => id}, _session, socket) do
post = Blog.get_post!(id)
{:ok, assign(socket, :post, post)}
end
@impl true
def render(assigns) do
~H"""
<h1><%= @post.title %></h1>
<%!-- UI check — hide button if not owner --%>
<%= if @current_scope.user.id == @post.user_id do %>
<.button phx-click="delete" data-confirm="Are you sure?">
Delete
</.button>
<% end %>
"""
end
# Server check — ALWAYS verify ownership
@impl true
def handle_event("delete", _params, socket) do
post = socket.assigns.post
if socket.assigns.current_scope.user.id == post.user_id do
{:ok, _} = Blog.delete_post(post)
{:noreply, push_navigate(socket, to: ~p"/posts")}
else
{:noreply, put_flash(socket, :error, "Not authorized")}
end
end
end
The simplest and most common authorization pattern. Extract it for reuse.
defmodule MyAppWeb.PostLive.Edit do
use MyAppWeb, :live_view
@impl true
def mount(%{"id" => id}, _session, socket) do
post = Blog.get_post!(id)
if authorized?(socket, post) do
changeset = Blog.change_post(post)
{:ok, assign(socket, post: post, form: to_form(changeset))}
else
{:ok,
socket
|> put_flash(:error, "Not authorized")
|> push_navigate(to: ~p"/posts")}
end
end
@impl true
def handle_event("save", %{"post" => params}, socket) do
post = socket.assigns.post
if authorized?(socket, post) do
case Blog.update_post(post, params) do
{:ok, post} ->
{:noreply, push_navigate(socket, to: ~p"/posts/#{post}")}
{:error, changeset} ->
{:noreply, assign(socket, :form, to_form(changeset))}
end
else
{:noreply, put_flash(socket, :error, "Not authorized")}
end
end
defp authorized?(socket, resource) do
socket.assigns.current_scope.user.id == resource.user_id
end
end
The strongest authorization pattern: queries only return data the user owns. No separate check needed.
defmodule MyApp.Blog do
import Ecto.Query
# Scoped — only returns posts owned by this user
def list_user_posts(%Scope{user: user}) do
Post
|> where(user_id: ^user.id)
|> order_by(desc: :inserted_at)
|> Repo.all()
end
# Scoped get — returns nil if not owned by user
def get_user_post(%Scope{user: user}, id) do
Post
|> where(user_id: ^user.id)
|> Repo.get(id)
end
# Scoped update — only updates if owned
def update_user_post(%Scope{user: user}, %Post{} = post, attrs) do
if post.user_id == user.id do
post
|> Post.changeset(attrs)
|> Repo.update()
else
{:error, :unauthorized}
end
end
# Scoped delete — only deletes if owned
def delete_user_post(%Scope{user: user}, %Post{} = post) do
if post.user_id == user.id do
Repo.delete(post)
else
{:error, :unauthorized}
end
end
end
@impl true
def mount(_params, _session, socket) do
scope = socket.assigns.current_scope
posts = Blog.list_user_posts(scope)
{:ok, assign(socket, :posts, posts)}
end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
scope = socket.assigns.current_scope
post = Blog.get_user_post(scope, id)
case Blog.delete_user_post(scope, post) do
{:ok, _} -> {:noreply, update(socket, :posts, &Enum.reject(&1, fn p -> p.id == post.id end))}
{:error, :unauthorized} -> {:noreply, put_flash(socket, :error, "Not authorized")}
end
end
For applications with complex permissions (roles, teams, org-level access), extract authorization into policy modules.
defmodule MyApp.Policy do
alias MyApp.Accounts.User
alias MyApp.Blog.Post
def authorize(%User{role: :admin}, _action, _resource), do: :ok
def authorize(%User{id: user_id}, :edit, %Post{user_id: user_id}), do: :ok
def authorize(%User{id: user_id}, :delete, %Post{user_id: user_id}), do: :ok
def authorize(%User{}, :view, %Post{published: true}), do: :ok
def authorize(_user, _action, _resource), do: {:error, :unauthorized}
end
# Usage in LiveView
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = socket.assigns.current_scope.user
post = Blog.get_post!(id)
case Policy.authorize(user, :delete, post) do
:ok ->
{:ok, _} = Blog.delete_post(post)
{:noreply, push_navigate(socket, to: ~p"/posts")}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, "Not authorized")}
end
end
Same principles apply in traditional controllers.
defmodule MyAppWeb.PostController do
use MyAppWeb, :controller
def delete(conn, %{"id" => id}) do
user = conn.assigns.current_scope.user
post = Blog.get_post!(id)
if user.id == post.user_id do
{:ok, _} = Blog.delete_post(post)
redirect(conn, to: ~p"/posts")
else
conn
|> put_flash(:error, "Not authorized")
|> redirect(to: ~p"/posts")
end
end
end
Always use data-confirm on buttons that delete or irreversibly modify data.
# In HEEx template
<.button phx-click="delete" phx-value-id={post.id} data-confirm="Are you sure?">
Delete
</.button>
# For links
<.link href={~p"/posts/#{post}"} method="delete" data-confirm="Delete this post?">
Delete
</.link>
Test that unauthorized users cannot perform actions, not just that authorized users can.
describe "authorization" do
test "owner can delete their post", %{conn: conn} do
user = user_fixture()
post = post_fixture(user_id: user.id)
conn = log_in_user(conn, user)
{:ok, lv, _html} = live(conn, ~p"/posts/#{post}")
lv |> element("button", "Delete") |> render_click()
assert_redirect(lv, ~p"/posts")
end
test "non-owner cannot delete post", %{conn: conn} do
owner = user_fixture()
other_user = user_fixture()
post = post_fixture(user_id: owner.id)
conn = log_in_user(conn, other_user)
{:ok, lv, _html} = live(conn, ~p"/posts/#{post}")
# Delete button should not be visible
refute render(lv) =~ "Delete"
# Even if they craft the event, server rejects it
assert render_click(lv, "delete") =~ "Not authorized"
end
test "scoped query returns only user's posts" do
user1 = user_fixture()
user2 = user_fixture()
post1 = post_fixture(user_id: user1.id)
_post2 = post_fixture(user_id: user2.id)
scope = %Scope{user: user1}
posts = Blog.list_user_posts(scope)
assert length(posts) == 1
assert hd(posts).id == post1.id
end
end
See phoenix-liveview-auth skill for authentication (who you are).
See testing-essentials skill for comprehensive testing patterns.