Skill v1.0.0
currentAutomated scan100/100version: "1.0.0" name: phoenix-authorization-patterns description: MANDATORY for ALL authorization and access control work. Invoke before writing permission checks, policy modules, or role-based access. file_patterns:
- "**/*_live.ex"
- "**/_live/.ex"
- "/controllers//*.ex"
auto_suggest: true
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.id` against the resource's `user_id` — never trust client-sent user IDs
- Use policy modules for complex authorization — don't inline permission checks in LiveViews or controllers
- Add `data-confirm` attribute 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 douse MyAppWeb, :live_view@impl truedef mount(%{"id" => id}, _session, socket) dopost = Blog.get_post!(id){:ok, assign(socket, :post, post)}end@impl truedef 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 truedef handle_event("delete", _params, socket) dopost = socket.assigns.postif 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")}endendend
Owner-Only Pattern
The simplest and most common authorization pattern. Extract it for reuse.
defmodule MyAppWeb.PostLive.Edit douse MyAppWeb, :live_view@impl truedef mount(%{"id" => id}, _session, socket) dopost = Blog.get_post!(id)if authorized?(socket, post) dochangeset = 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")}endend@impl truedef handle_event("save", %{"post" => params}, socket) dopost = socket.assigns.postif authorized?(socket, post) docase 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))}endelse{:noreply, put_flash(socket, :error, "Not authorized")}endenddefp authorized?(socket, resource) dosocket.assigns.current_scope.user.id == resource.user_idendend
Scoped Queries in Contexts
The strongest authorization pattern: queries only return data the user owns. No separate check needed.
defmodule MyApp.Blog doimport Ecto.Query# Scoped — only returns posts owned by this userdef list_user_posts(%Scope{user: user}) doPost|> where(user_id: ^user.id)|> order_by(desc: :inserted_at)|> Repo.all()end# Scoped get — returns nil if not owned by userdef get_user_post(%Scope{user: user}, id) doPost|> where(user_id: ^user.id)|> Repo.get(id)end# Scoped update — only updates if owneddef update_user_post(%Scope{user: user}, %Post{} = post, attrs) doif post.user_id == user.id dopost|> Post.changeset(attrs)|> Repo.update()else{:error, :unauthorized}endend# Scoped delete — only deletes if owneddef delete_user_post(%Scope{user: user}, %Post{} = post) doif post.user_id == user.id doRepo.delete(post)else{:error, :unauthorized}endendend
Using Scoped Contexts in LiveViews
@impl truedef mount(_params, _session, socket) doscope = socket.assigns.current_scopeposts = Blog.list_user_posts(scope){:ok, assign(socket, :posts, posts)}end@impl truedef handle_event("delete", %{"id" => id}, socket) doscope = socket.assigns.current_scopepost = 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")}endend
Policy Modules
For applications with complex permissions (roles, teams, org-level access), extract authorization into policy modules.
defmodule MyApp.Policy doalias MyApp.Accounts.Useralias MyApp.Blog.Postdef authorize(%User{role: :admin}, _action, _resource), do: :okdef authorize(%User{id: user_id}, :edit, %Post{user_id: user_id}), do: :okdef authorize(%User{id: user_id}, :delete, %Post{user_id: user_id}), do: :okdef authorize(%User{}, :view, %Post{published: true}), do: :okdef authorize(_user, _action, _resource), do: {:error, :unauthorized}end# Usage in LiveView@impl truedef handle_event("delete", %{"id" => id}, socket) douser = socket.assigns.current_scope.userpost = 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")}endend
Controller Authorization
Same principles apply in traditional controllers.
defmodule MyAppWeb.PostController douse MyAppWeb, :controllerdef delete(conn, %{"id" => id}) douser = conn.assigns.current_scope.userpost = Blog.get_post!(id)if user.id == post.user_id do{:ok, _} = Blog.delete_post(post)redirect(conn, to: ~p"/posts")elseconn|> put_flash(:error, "Not authorized")|> redirect(to: ~p"/posts")endendend
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" dotest "owner can delete their post", %{conn: conn} douser = 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")endtest "non-owner cannot delete post", %{conn: conn} doowner = 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 visiblerefute render(lv) =~ "Delete"# Even if they craft the event, server rejects itassert render_click(lv, "delete") =~ "Not authorized"endtest "scoped query returns only user's posts" douser1 = 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) == 1assert hd(posts).id == post1.idendend
See phoenix-liveview-auth skill for authentication (who you are). See testing-essentials skill for comprehensive testing patterns.