A bookmark from 2016
While tidying my Org files I stumbled on a TODO I had left myself years ago:
*** TODO Straightforward auth system in Phoenix :elixir:
DEADLINE: <2018-03-20 Mar>
[2016-12-09 Ven]
See "Programming Phoenix" when you don't need a powerful auth system
like Pow.
The intent was clear enough. When I didn’t need the full machinery of a
library like Pow, I would follow the chapter in Programming Phoenix and
write the authentication code by hand: a User schema, password hashing,
a session, a couple of plugs. “Straightforward” meant “small and mine”,
as opposed to “pull in a dependency and configure it”.
That advice was reasonable in 2016. It’s wrong now, and the reason it’s wrong is a happy one: the trade-off it was built on has disappeared.
What “simple” means today
The false choice back then was heavyweight library versus hand-rolled
code. Phoenix erased it. Since 1.6, the framework ships a generator,
mix phx.gen.auth, that writes a complete authentication system directly
into your application. There is no auth library at runtime, no magic to
learn, no upgrade treadmill. It’s plain Phoenix code that lives in your repo
and that you’re free to read and edit — exactly the “small and mine” I wanted,
except I no longer type it out and get the security details subtly wrong.
Phoenix 1.8 took this a good deal further. The generated system now gives you, out of the box:
- Magic-link login by default — passwordless, with password auth as an opt-in.
- Sudo mode — sensitive actions require a recent authentication.
- Scopes — a first-class way to bind every data access to the current user.
So the modern answer to my 2016 note is almost cheeky: don’t reach for a library, and don’t hand-roll it. Generate it.
Generating it
Let’s build a tiny journal app to see the whole thing.
mix phx.new journal
cd journal
mix ecto.create
mix phx.gen.auth Accounts User users --live
The three arguments are the context module (Accounts), the schema module
(User), and the table name (users). The generator prints what it created,
reminds you to fetch the new dependency (the password hasher), and asks you to
run the migration:
mix deps.get
mix ecto.migrate
That’s it, you have a working auth system. A couple of flags are worth knowing:
- LiveView or controllers. Run the generator interactively and it asks
whether you want LiveView views or conventional controllers. Pass
--live(as above) or--no-liveto answer up front. There is no silent default: answer the prompt or pass the flag. --hashing-lib bcrypt|pbkdf2|argon2picks the password hasher for the opt-in password feature.
Everything below assumes the LiveView flow (--live) and magic links.
Reading what it wrote
This is the part I enjoy. Nothing is hidden, so let’s walk the generated code from the outside in.
The context
Accounts is an ordinary Phoenix context, and its public functions read like a
description of the feature set:
Accounts.get_user_by_email(email)
Accounts.register_user(attrs)
# magic link
Accounts.deliver_login_instructions(user, url_fun)
Accounts.get_user_by_magic_link_token(token)
Accounts.login_user_by_magic_link(token)
# sessions
Accounts.generate_user_session_token(user)
Accounts.get_user_by_session_token(token)
Accounts.delete_user_session_token(token)
# settings, guarded by sudo mode
Accounts.sudo_mode?(user)
Accounts.update_user_email(user, token)
Accounts.update_user_password(user, attrs)
There is no get_user_by_email_and_password in your face unless you enable
passwords — the magic-link path is the spine of the design.
The token table
All sessions and email tokens live in a dedicated users_tokens table, not on
the user row. Two details matter for security, and the generator gets both
right so you don’t have to:
- The tokens delivered by email (magic link, email-change confirmation) are stored only as a SHA-256 hash. A database leak can’t be replayed as a login link.
- Whenever the password changes, every token is deleted, logging the user out everywhere.
This is precisely the kind of thing I would have half-remembered to do if I’d hand-rolled it from the book.
The auth module
JournalWeb.UserAuth is where HTTP meets the context. Its public surface:
log_in_user(conn, user) # sets the session + remember-me cookie
log_out_user(conn) # clears session, disconnects live sockets
fetch_current_scope_for_user/2 # plug: loads the current scope into assigns
require_authenticated_user/2 # plug: bounce anonymous visitors
The heavy lifting for the LiveView flow happens in on_mount hooks, which the
same module exposes and the router references by atom:
:mount_current_scope— make the current user available, don’t require one.:require_authenticated— require a logged-in user.:require_sudo_mode— require a recent authentication.
Notice the assign it populates isn’t :current_user anymore. It’s
:current_scope. Hold that thought: scopes are the headline change and get
their own section below.
The router
The generator injects a plug into the :browser pipeline and adds two groups
of routes:
pipeline :browser do
# ...
plug :fetch_current_scope_for_user
end
scope "/", JournalWeb do
pipe_through [:browser, :require_authenticated_user]
live_session :require_authenticated_user,
on_mount: [{JournalWeb.UserAuth, :require_authenticated}] do
live "/users/settings", UserLive.Settings, :edit
live "/users/settings/confirm-email/:token", UserLive.Settings, :confirm_email
end
end
scope "/", JournalWeb do
pipe_through [:browser]
live_session :current_user,
on_mount: [{JournalWeb.UserAuth, :mount_current_scope}] do
live "/users/register", UserLive.Registration, :new
live "/users/log-in", UserLive.Login, :new
live "/users/log-in/:token", UserLive.Confirmation, :new
end
post "/users/log-in", UserSessionController, :create
delete "/users/log-out", UserSessionController, :delete
end
Two live_session blocks, one that demands authentication and one that
doesn’t, each wiring the right on_mount hook. Protecting your own routes is
now a matter of dropping them into the first block.
The magic-link flow
Passwordless login is the default, and the flow is short:
- The visitor types their email on
/users/log-in. deliver_login_instructions/2builds a one-time token, stores its hash, and emails a link to/users/log-in/:token.- Clicking the link hits
login_user_by_magic_link/1, which validates the token and, on a brand-new account, also confirms it in the same step. log_in_user/2establishes the session.
No password to choose, forget, or leak. In development the email is printed to
a local mailbox (the generated Journal.Mailer plus Swoosh’s preview at
/dev/mailbox), so you can click through without configuring SMTP.
If you do want passwords, they’re opt-in: the settings page lets a user set
one, and get_user_by_email_and_password/2 plus update_user_password/2 are
already there for you.
Sudo mode
Some actions shouldn’t ride on a week-old session cookie. Changing your email or password is the obvious case. Phoenix models this as sudo mode: an action is allowed only if the user authenticated recently.
“Recently” is a 20-minute window by default, checked by sudo_mode?/2 against
the user’s authenticated_at timestamp. In the LiveView flow you enforce it
with the :require_sudo_mode on_mount hook (the generated
UserLive.Settings already wires it up); the controller flow gets an
equivalent require_sudo_mode plug. If the window has lapsed, the user is sent
back through a quick re-authentication before the sensitive form is shown.
It’s a small feature with an outsized payoff: the dangerous operations are fenced off by default, and you’d have to go out of your way to make them insecure.
Scopes: the 1.8 headliner
Here’s the change that most repays a careful read. Authentication tells you
who is making a request; authorization is making sure they only touch
their own data. That second half is where hand-rolled auth quietly rots — one
context function forgets its where user_id == ... clause and you’ve got a data
leak.
Phoenix 1.8 attacks this with scopes. The generator creates a small struct:
defmodule Journal.Accounts.Scope do
alias Journal.Accounts.User
defstruct user: nil
def for_user(%User{} = user), do: %__MODULE__{user: user}
def for_user(nil), do: nil
end
The auth plug builds it from the session and drops it into assigns:
def fetch_current_scope_for_user(conn, _opts) do
{user_token, conn} = ensure_user_token(conn)
user = user_token && Accounts.get_user_by_session_token(user_token)
assign(conn, :current_scope, Scope.for_user(user))
end
A block of config in config/config.exs marks this scope as the default,
tells the generators how it maps onto your schemas, and wires up the test
helpers:
config :journal, :scopes,
user: [
default: true,
module: Journal.Accounts.Scope,
assign_key: :current_scope,
access_path: [:user, :id],
schema_key: :user_id,
schema_type: :id,
schema_table: :users,
test_data_fixture: Journal.AccountsFixtures,
test_setup_helper: :register_and_log_in_user
]
Now the payoff. Generate a resource the normal way:
mix phx.gen.live Notes Note notes title:string body:text
Because a default scope exists, the generated context threads the scope through every function and filters on it:
def list_notes(%Scope{} = scope) do
Repo.all_by(Note, user_id: scope.user.id)
end
def get_note!(%Scope{} = scope, id) do
Repo.get_by!(Note, id: id, user_id: scope.user.id)
end
def create_note(%Scope{} = scope, attrs) do
with {:ok, note = %Note{}} <-
%Note{}
|> Note.changeset(attrs, scope)
|> Repo.insert() do
broadcast_note(scope, {:created, note})
{:ok, note}
end
end
The scope even reaches into the changeset: Note.changeset(note, attrs, scope)
is what stamps user_id, so a note can’t be created outside the current
scope’s user even if you tried. And the generated Index LiveView calls the
scoped context with the scope straight from its assigns:
def mount(_params, _session, socket) do
{:ok,
socket
|> assign(:page_title, "Listing Notes")
|> stream(:notes, list_notes(socket.assigns.current_scope))}
end
The scope becomes the only door to your data. Isolation stops being a rule you have to remember on every query and becomes the shape the code is generated in. That is a genuinely better default than anything I’d have written by hand.
Push the scope further
Here’s the part the “getting started” tour skips, and it’s the most useful
thing to internalize: the scope is yours to extend. The generated struct
happens to hold a user, but nothing says it has to stop there. The day your app
grows teams or organizations (the day most auth code turns into a swamp of
where organization_id == ... clauses), you add one field:
defmodule Journal.Accounts.Scope do
alias Journal.Accounts.User
defstruct user: nil, organization: nil
def for_user(%User{} = user) do
%__MODULE__{user: user, organization: user.current_organization}
end
def for_user(nil), do: nil
end
Every scoped query can now filter on scope.organization.id instead of (or as
well as) the user, and every generated context already receives the scope. Your
tenant boundary lives in exactly one struct, resolved once per request. That’s
the real reason scopes exist, and it’s why I’d now start even a single-user app
this way: the upgrade path to multi-tenant is a field, not a rewrite.
Tips the docs don’t shout
A handful of things I wish someone had told me on day one:
-
It’s
@current_scope, not@current_user. In your HEEX it’s@current_scope.user, and@current_scopeisnilfor anonymous visitors — so guard it:<%= if @current_scope do %>. Muscle memory from older Phoenix will trip you here. -
Scoped contexts blow up for anonymous users.
Scope.for_user(nil)returnsnil, and a function head likelist_notes(%Scope{} = scope)won’t matchnil— it crashes. Keep scoped resources behind:require_authenticated, or give them a separate public code path. This bites the moment you link to a scaffolded resource from a public page. -
Click your magic links without SMTP. In development the email lands in the Swoosh preview at
/dev/mailbox. No mail server, no copy-pasting tokens: open the mailbox, click the link. -
The vague login message is a feature. “If your email is in our system, you’ll receive a link” isn’t hand-waving; it stops attackers from probing which emails have accounts. Resist the urge to make it more “helpful”.
-
Generate auth first. Run
phx.gen.authbefore you scaffold your resources. Do it after and the older contexts aren’t scoped — you’ll be hand-addinguser_idcolumns,belongs_to, and scope arguments to catch up. -
20 minutes isn’t sacred. The sudo-mode window is just a value. For an app where “sensitive” really means sensitive, shorten it.
When it’s enough — and when it isn’t
For the overwhelming majority of applications, this is the whole story. It’s
your code, so adapt it freely: protect routes by moving them into the
authenticated live_session, tweak the sudo-mode window, add the opt-in
password, restyle the pages.
You outgrow it in one direction: third-party identity. The moment you need “Sign in with GitHub/Google”, reach for Assent to handle the OAuth dance — and keep everything above as the local half of the system. Pow still exists and is fine, but the case for it as a default is much weaker than it was in 2016.
Closing the bookmark
So I can finally mark that TODO as DONE. The task was “figure out
straightforward auth in Phoenix”, and the answer aged in a direction I didn’t
predict. In 2016, straightforward meant type it out yourself from the book.
In 2026 it means let the generator write it, then read what it wrote — and
what it writes is safer, passwordless-first, and scoped by construction.
Ten years is a long time to leave a note open. This is a good one to close.
Have comments or want to discuss this topic?
Send an email to ~bounga/bounga.org-discuss@lists.sr.ht