Phoenix LiveView Authentication
RULES — Follow these with no exceptions
- Always use
on_mountcallbacks for LiveView auth — never check auth inmount/3directly;on_mountruns before mount and centralizes auth logic - Use
mount_current_scope/2to extract scope from session — never access session tokens manually or parse session data in LiveViews - Handle both
:contand:haltreturns fromon_mount—:haltmust redirect with a flash message, never silently drop the connection - Resolve Controller/LiveView name clashes the way the 1.8 generator does —
UserAuthcontains both conn plugs and on_mount hooks. ImportPhoenix.Controllernormally (the plugs needredirect/2), and fully qualify the LiveView calls inside on_mount hooks:Phoenix.LiveView.redirect(socket, to: ...)andPhoenix.LiveView.put_flash(socket, :error, ...). Excluding the Controller imports breaks the plug half of the module. - Guard the nil scope, not the assign lookup —
mount_current_scopealways assigns:current_scope, so@current_scopeis safe; the hazard is calling.useron a nil scope. Write@current_scope && @current_scope.user. Bracket accessassigns[:current_scope]is only needed when the assign may be entirely absent (e.g. layouts shared with non-auth live_sessions). - Test auth redirects by asserting
{:error, {:redirect, %{to: path}}}— don't test auth by checking rendered content; verify the redirect tuple fromlive/2 - Define
on_mounthooks once, reference vialive_sessionin router — never duplicate auth logic across LiveView modules
on_mount Authentication Pattern
The standard pattern for LiveView authentication. Define once, use everywhere via live_session.
defmodule MyAppWeb.UserAuth do
use MyAppWeb, :verified_routes
import Plug.Conn
import Phoenix.Controller
# Called by live_session :require_authenticated_user
def on_mount(:require_authenticated_user, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope && socket.assigns.current_scope.user do
{:cont, socket}
else
socket =
socket
|> Phoenix.LiveView.put_flash(:error, "You must log in to access this page.")
|> Phoenix.LiveView.redirect(to: ~p"/users/log-in")
{:halt, socket}
end
end
# Called by live_session :redirect_if_authenticated
def on_mount(:redirect_if_authenticated, _params, session, socket) do
socket = mount_current_scope(socket, session)
if socket.assigns.current_scope && socket.assigns.current_scope.user do
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
else
{:cont, socket}
end
end
# Conn plug — needs the plain Phoenix.Controller redirect/2
def require_authenticated_user(conn, _opts) do
if conn.assigns[:current_scope] && conn.assigns.current_scope.user do
conn
else
conn
|> put_flash(:error, "You must log in to access this page.")
|> redirect(to: ~p"/users/log-in")
|> halt()
end
end
# Called by live_session :mount_current_scope (public pages)
def on_mount(:mount_current_scope, _params, session, socket) do
{:cont, mount_current_scope(socket, session)}
end
defp mount_current_scope(socket, session) do
Phoenix.Component.assign_new(socket, :current_scope, fn ->
if user = find_user_from_session(session) do
%Scope{user: user}
end
end)
end
defp find_user_from_session(%{"user_token" => token}) do
Accounts.get_user_by_session_token(token)
end
defp find_user_from_session(_session), do: nil
end
Router Integration
Use live_session to apply on_mount hooks to groups of LiveViews. Each session shares auth requirements.
defmodule MyAppWeb.Router do
use MyAppWeb, :router
# Public pages — scope is mounted but not required
live_session :mount_current_scope,
on_mount: [{MyAppWeb.UserAuth, :mount_current_scope}] do
scope "/", MyAppWeb do
pipe_through :browser
live "/", HomeLive.Index
end
end
# Authenticated pages — redirects to login if not authenticated
live_session :require_authenticated_user,
on_mount: [{MyAppWeb.UserAuth, :require_authenticated_user}] do
scope "/", MyAppWeb do
pipe_through [:browser, :require_authenticated_user]
live "/dashboard", DashboardLive.Index
live "/settings", SettingsLive.Index
end
end
# Guest-only pages — redirects to home if already authenticated
live_session :redirect_if_authenticated,
on_mount: [{MyAppWeb.UserAuth, :redirect_if_authenticated}] do
scope "/", MyAppWeb do
pipe_through [:browser, :redirect_if_authenticated]
live "/users/register", UserRegistrationLive
live "/users/log-in", UserLoginLive
end
end
end
Import Conflict Resolution
Phoenix.Controller and Phoenix.LiveView both export redirect/2 and put_flash/3. UserAuth needs both — conn plugs for the router pipeline, and on_mount hooks for LiveView — so excluding one side's imports breaks the other half of the module. The 1.8 generator resolves this by importing Phoenix.Controller normally and fully qualifying the LiveView calls:
# Bad — excluding Phoenix.Controller breaks the plug functions,
# which need plain redirect/2 and put_flash/3
import Phoenix.LiveView
import Phoenix.Controller, except: [redirect: 2, put_flash: 3]
# Good — import Phoenix.Controller normally for the plugs;
# fully qualify inside on_mount hooks instead
import Phoenix.Controller
def on_mount(:require_authenticated_user, _params, session, socket) do
# ...
Phoenix.LiveView.redirect(socket, to: ~p"/")
Phoenix.LiveView.put_flash(socket, :error, "...")
end
def require_authenticated_user(conn, _opts) do
# plain redirect/2 and put_flash/3 here come from Phoenix.Controller
conn |> put_flash(:error, "...") |> redirect(to: ~p"/users/log-in")
end
current_scope vs current_user
Phoenix 1.8+ uses Scope structs instead of raw current_user. The scope wraps the user and can carry additional context.
# Phoenix 1.8+ pattern — Scope struct
defmodule MyApp.Scope do
defstruct [:user]
end
# In LiveView — access user through scope
def mount(_params, _session, socket) do
user = socket.assigns.current_scope.user
{:ok, assign(socket, :posts, Posts.list_posts(user))}
end
# In templates — @current_scope is always assigned once on_mount has
# run, so dot access is safe; guard .user since the scope may wrap no user
<%= if @current_scope && @current_scope.user do %>
<p>Welcome, <%= @current_scope.user.email %></p>
<% end %>
Guarding a Nil Scope
mount_current_scope/2 always assigns :current_scope (via assign_new), so @current_scope is safe to dot-access once an on_mount hook has run. The real hazard is calling .user when nobody is logged in — the scope itself is non-nil, but scope.user is nil.
# Bad — crashes when current_scope.user is nil (guest visitor)
<%= @current_scope.user.email %>
# Good — guard .user, not the current_scope lookup
<%= if @current_scope && @current_scope.user do %>
<%= @current_scope.user.email %>
<% end %>
Bracket access assigns[:current_scope] is only needed when the assign may be entirely absent — for example, a layout shared with a live_session that never runs the mount_current_scope on_mount hook:
<%= if assigns[:current_scope] && @current_scope.user do %>
<%= @current_scope.user.email %>
<% end %>
Testing LiveView Auth
Testing Protected Routes
describe "require_authenticated_user" do
test "redirects if not logged in", %{conn: conn} do
assert {:error, {:redirect, %{to: "/users/log-in"}}} =
live(conn, ~p"/dashboard")
end
test "renders page when authenticated", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
{:ok, _lv, html} = live(conn, ~p"/dashboard")
assert html =~ "Dashboard"
end
end
describe "redirect_if_authenticated" do
test "redirects if already logged in", %{conn: conn} do
user = user_fixture()
conn = log_in_user(conn, user)
assert {:error, {:redirect, %{to: "/"}}} =
live(conn, ~p"/users/log-in")
end
end
Testing on_mount Directly
describe "on_mount: :require_authenticated_user" do
test "authenticates user from session", %{conn: conn} do
user = user_fixture()
token = Accounts.generate_user_session_token(user)
assert {:cont, updated_socket} =
UserAuth.on_mount(
:require_authenticated_user,
%{},
%{"user_token" => token},
%LiveView.Socket{
endpoint: MyAppWeb.Endpoint,
assigns: %{__changed__: %{}}
}
)
assert updated_socket.assigns.current_scope.user.id == user.id
end
test "redirects when no session token" do
assert {:halt, updated_socket} =
UserAuth.on_mount(
:require_authenticated_user,
%{},
%{},
%LiveView.Socket{
endpoint: MyAppWeb.Endpoint,
assigns: %{__changed__: %{}, flash: %{}}
}
)
assert updated_socket.redirected == {:redirect, %{to: "/users/log-in"}}
end
end
See testing-essentials skill for comprehensive testing patterns.
See phoenix-authorization-patterns skill for authorization after authentication.