Phoenix authorization patterns
Skill j-morgan6/elixir-phoenix-guide/skills/phoenix-authorization-patterns
Use when deciding who may do what — ownership checks, policy modules, scoped queries, role-based access in LiveViews and controllers.From its SKILL.md
npx -y skills add j-morgan6/elixir-phoenix-guide --skill phoenix-authorization-patternsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
SKILL.md
8.4 KB, as published. Nobody here has run it
Phoenix Authorization Patterns
RULES — Follow these with no exceptions
- Always authorize on the server in event handlers — UI-only checks (hiding buttons) are not security; always verify in
handle_event/3 - Verify resource ownership by comparing
current_scope.user.idagainst the resource'suser_id— never trust client-sent user IDs - Use policy modules for complex authorization — don't inline permission checks in LiveViews or controllers
- Add
data-confirmattribute for destructive UI actions — client-side confirmation before server round-trip - Test both authorized and unauthorized paths — every
handle_eventthat mutates data needs an authz test proving unauthorized access is rejected - Scope queries to the current user in contexts —
where(user_id: ^user_id)prevents IDOR vulnerabilities
Server-Side Authorization in LiveViews
UI 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
Owner-Only Pattern
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
Scoped Queries in Contexts
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
Using Scoped Contexts in LiveViews
@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
# get_user_post/2 is scoped — it returns nil for posts that don't exist
# OR that exist but aren't owned by this user, so nil must be handled
# before calling delete_user_post/2
case Blog.get_user_post(scope, id) do
nil ->
{:noreply, put_flash(socket, :error, "Not found")}
post ->
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
end
Policy Modules
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
Controller Authorization
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
data-confirm for Destructive Actions
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>
Testing Authorization
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.