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-live to answer up front. There is no silent default: answer the prompt or pass the flag.
  • --hashing-lib bcrypt|pbkdf2|argon2 picks 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.

Passwordless login is the default, and the flow is short:

  1. The visitor types their email on /users/log-in.
  2. deliver_login_instructions/2 builds a one-time token, stores its hash, and emails a link to /users/log-in/:token.
  3. 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.
  4. log_in_user/2 establishes 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_scope is nil for 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) returns nil, and a function head like list_notes(%Scope{} = scope) won’t match nil — 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.auth before you scaffold your resources. Do it after and the older contexts aren’t scoped — you’ll be hand-adding user_id columns, 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