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.
How this skill is triggered — by the user, by Claude, or both
Slash command
/elixir-phoenix-guide:phoenix-authorization-patternsThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
1. **Always authorize on the server in event handlers** — UI-only checks (hiding buttons) are not security; always verify in `handle_event/3`
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.
npx claudepluginhub j-morgan6/elixir-phoenix-guide --plugin elixir-phoenix-guideImplements Phoenix LiveView authentication patterns using on_mount hooks, mount_current_scope for session handling, and live_session router blocks. Invoke before writing auth logic in LiveViews.
Enforces Elixir/Phoenix security patterns for auth, OAuth, sessions, CSRF, XSS, SQL injection, input validation, and secrets. Activates when editing auth files, login flows, RBAC, or API keys.
Provides Phoenix LiveView best practices: no DB queries in mount (called twice), load data in handle_params, security scopes, scoped PubSub topics, GenServer polling, async assigns, and gotchas.