Build a Phoenix application where someone registers a username and passkey, saves recovery codes, opens a protected page and signs out. This guide uses open registration. Add invitation-only registration once this flow works.
You need Elixir 1.18 or newer, PostgreSQL, Phoenix 1.8 and LiveView 1.1. The examples below use
the layouts, components and asset configuration generated by Phoenix 1.8. For SQLite, generate
the project with --database sqlite3; for MySQL, use --database mysql. Apply the corresponding
database settings before migrating.
$ mix phx.new my_app
$ cd my_app
$ mix ecto.create
Keep the files generated by Phoenix unless a step explicitly replaces them. For a complete application to compare against, see the open-registration example.
1. Add the dependency
Add Ithibati to the existing dependency list in mix.exs:
{:ithibati, "~> 0.1"}Then fetch it:
$ mix deps.get
2. Configure it
Add this to config/config.exs, above its final import_config line:
config :ithibati,
repo: MyApp.Repo,
user_schema: MyApp.Accounts.User,
users_key_type: :id:id matches the account table this guide creates. If your application uses UUID primary keys,
see Configuration and schemas.
users_key_type and table_prefix are compile-time settings and belong in config/config.exs.
3. The account schema
Create lib/my_app/accounts/user.ex:
defmodule MyApp.Accounts.User do
use Ecto.Schema
alias Ithibati.Schema.Identifier
alias Ithibati.Schema.User
use User, identifier: :username, format: Identifier.username_format()
import Ecto.Changeset
schema "users" do
ithibati_account()
field :name, :string
timestamps(type: :utc_datetime_usec)
end
def changeset(user, attrs) do
user
|> identifier_changeset(attrs)
|> cast(attrs, [:name])
end
endithibati_account() declares username and the passkey, recovery-code and session associations.
The changeset trims and lowercases the username, requires it, and checks its format. The built-in
username format accepts ASCII letters, digits and underscores, up to thirty characters.
The name field belongs to this example application. Other identifier fields, custom formats
and display names are covered in Configuration and schemas.
4. The migration
Generate two migrations, in this order:
$ mix ecto.gen.migration create_users
$ mix ecto.gen.migration add_ithibati
In the generated *_create_users.exs file under priv/repo/migrations/, create your account table:
defmodule MyApp.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :username, :string, null: false
add :name, :string
timestamps(type: :utc_datetime_usec)
end
end
endIn *_add_ithibati.exs, create Ithibati's tables and the unique index on users.username:
defmodule MyApp.Repo.Migrations.AddIthibati do
use Ecto.Migration
def up, do: Ithibati.Migration.up(version: 4)
def down, do: Ithibati.Migration.down(version: 4)
endKeep version: 4 pinned. The account table must exist before this migration runs.
Ithibati checks its identifier and primary-key columns before creating its own tables.
$ mix ecto.migrate
You now have the account table, the identifier's unique index, and Ithibati's passkey, recovery-code, session and bootstrap tables. See Migration behaviour for ownership, deletion and upgrade details.
5. The handler
Create lib/my_app_web/auth.ex. This handler approves registration, creates the account and its
credentials in one transaction, and signs in verified accounts:
defmodule MyAppWeb.Auth do
@behaviour Ithibati.Web.Handler
import Phoenix.Controller, only: [json: 2]
import Plug.Conn, only: [put_session: 3]
alias Ecto.Multi
alias Ithibati.Identity.Grant
alias Ithibati.Web.Gate
alias MyApp.Accounts.User
alias MyApp.Repo
@impl true
def registration_subject(_conn, %{"username" => username}) do
%User{}
|> User.changeset(%{"username" => username})
|> Ecto.Changeset.apply_action(:insert)
|> case do
{:ok, account} -> {:ok, account.username}
{:error, _changeset} -> {:error, :invalid_username}
end
end
def registration_subject(_conn, _params), do: {:error, :username_required}
@impl true
def register(conn, key_attrs, username, _params) do
Multi.new()
|> Multi.insert(:account, User.changeset(%User{}, %{"username" => username}))
|> Grant.with_key_and_codes(key_attrs)
|> Repo.transaction()
|> case do
{:ok, %{account: account, recovery_codes: codes}} ->
{:ok,
conn
|> Gate.log_in(account)
|> put_session(:recovery_codes, codes)
|> json(%{redirect: "/recovery-codes"})}
{:error, :account, %Ecto.Changeset{} = changeset, _changes} ->
if Ithibati.Schema.User.identifier_taken?(changeset),
do: {:error, :username_taken},
else: {:error, :invalid_username}
{:error, _step, reason, _changes} ->
{:error, reason}
end
end
@impl true
def authenticate(conn, account),
do: {:ok, conn |> Gate.log_in(account) |> json(%{redirect: "/inside"})}
@impl true
def recovered(conn, account, nil), do: authenticate(conn, account)
def recovered(conn, account, fresh) do
{:ok,
conn
|> Gate.log_in(account)
|> put_session(:recovery_codes, fresh)
|> json(%{redirect: "/recovery-codes"})}
end
endregistration_subject/2 validates the identifier before the browser opens its passkey dialog.
register/4 receives that approved identifier as its third argument. Use it when creating the
account; request parameters are sent again and may have changed.
Grant.with_key_and_codes/3 expects an account step named :account and adds :passkey and
:recovery_codes. Application steps that can refuse should go before the grant. Avoid logging
whole transaction results: they can contain plaintext recovery codes.
recovered/3 receives a fresh batch only when the last unused recovery code was spent. This
handler sends it to the same page used after registration. After Gate.log_in/2, finish with a
full page load so the browser receives the renewed session and CSRF token. The hook follows
the JSON redirect value for you.
6. Showing the recovery codes
Create lib/my_app_web/controllers/session_controller.ex:
defmodule MyAppWeb.SessionController do
use MyAppWeb, :controller
alias Ithibati.Web.Gate
def recovery_codes(conn, _params) do
case get_session(conn, :recovery_codes) do
nil -> redirect(conn, to: "/inside")
codes -> conn |> delete_session(:recovery_codes) |> render(:recovery_codes, codes: codes)
end
end
def sign_out(conn, _params), do: conn |> Gate.log_out() |> redirect(to: "/")
endThe controller removes the temporary session entry when it renders the codes. The database stores only their digests, so the application cannot retrieve the batch later. This is an HTTP controller because it must update the session cookie.
Create its HTML module at lib/my_app_web/controllers/session_html.ex:
defmodule MyAppWeb.SessionHTML do
use MyAppWeb, :html
embed_templates "session_html/*"
endCreate lib/my_app_web/controllers/session_html/recovery_codes.html.heex:
<Layouts.app flash={%{}}>
<.header>
Your recovery codes
<:subtitle>
Twelve, each good for one sign-in, and this is the only time they are shown.
</:subtitle>
</.header>
<div class="alert alert-warning mt-6">
<span>Save these codes somewhere safe. Use one if you lose access to your passkeys.</span>
</div>
<ul class="mt-6 grid grid-cols-2 gap-2 font-mono text-sm">
<li :for={code <- @codes} class="rounded bg-base-200 px-3 py-2">{code}</li>
</ul>
<p class="mt-6"><.link href={~p"/inside"} class="link">Done</.link></p>
</Layouts.app>The default batch contains twelve codes, each good for one sign-in. Recovery codes explains regeneration and automatic refill.
7. The routes
In lib/my_app_web/router.ex, import Ithibati.Web.Router, add the gate to the existing browser
pipeline, and add the ceremony pipeline and routes below.
Replace the generated get "/", PageController, :home route with the public LiveView route.
Keep any other generated scopes, including the dev_routes block; they are omitted here.
defmodule MyAppWeb.Router do
use MyAppWeb, :router
import Ithibati.Web.Router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_live_flash
plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
plug :protect_from_forgery
plug :put_secure_browser_headers
plug Ithibati.Web.Gate, :current_account
end
pipeline :ceremony do
plug :accepts, ["json"]
plug :fetch_session
plug :protect_from_forgery
end
scope "/auth" do
pipe_through :ceremony
ithibati_routes handler: MyAppWeb.Auth, rp_name: "MyApp"
end
scope "/", MyAppWeb do
pipe_through :browser
live_session :public, on_mount: [{Ithibati.Web.Gate, :current_account}] do
live "/", SignInLive
end
live_session :members, on_mount: [{Ithibati.Web.Gate, {:require_account, to: "/"}}] do
live "/inside", InsideLive
end
get "/recovery-codes", SessionController, :recovery_codes
delete "/session", SessionController, :sign_out
end
endThe ceremony pipeline accepts JSON, fetches the session that holds the challenge, and checks CSRF protection. Reusing the HTML-only browser pipeline would reject the hook's requests.
Gate assigns @current_account in both HTTP requests and LiveView mounts. The members session
requires an account and redirects visitors to / when none is present.
8. The browser
In assets/js/app.js, import Ithibati's hooks and merge them into the existing LiveSocket
configuration. Preserve the other generated options and hooks:
import {hooks as ithibatiHooks} from "ithibati"
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ...ithibatiHooks},
})The package import works with the Hex dependency and Phoenix 1.8's generated esbuild setup. For a local path dependency or the colocated manifest, see Browser imports.
9. The page people register and sign in on
Create lib/my_app_web/live/sign_in_live.ex and its parent directory. The registration form
collects a username. Sign-in needs only a button: the browser lets the person choose a passkey.
The recovery form sends a code to the recovery endpoint.
defmodule MyAppWeb.SignInLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket), do: {:ok, assign(socket, username: "", error: nil)}
@impl true
def handle_event("validate", %{"username" => username}, socket),
do: {:noreply, assign(socket, username: username, error: nil)}
def handle_event("register", %{"username" => username}, socket),
do:
{:noreply,
socket |> assign(error: nil) |> push_event("ithibati:register", %{username: username})}
def handle_event("sign-in", _params, socket),
do: {:noreply, socket |> assign(error: nil) |> push_event("ithibati:authenticate", %{})}
def handle_event("recover", %{"code" => code}, socket),
do: {:noreply, socket |> assign(error: nil) |> push_event("ithibati:recover", %{code: code})}
# A successful ceremony ends in the redirect the handler answered with, so only failures arrive
# back here.
def handle_event("ithibati:failed", %{"error" => error} = payload, socket),
do: {:noreply, assign(socket, error: message(error, payload["exception"]))}
def handle_event("ithibati:done", _payload, socket), do: {:noreply, socket}
defp message("ceremony_failed", name) when is_binary(name), do: "Your browser refused: #{name}."
defp message(error, _name), do: message(error)
defp message("username_taken"), do: "That username is taken."
defp message("invalid_username"),
do: "A username is letters, digits and underscores, up to thirty characters."
defp message("username_required"), do: "Pick a username to register."
defp message("no_credentials"), do: "No passkey is registered here yet."
defp message("invalid_code"), do: "That recovery code is not one we can use."
defp message("ceremony_cancelled"), do: "The passkey prompt was dismissed."
defp message("already_enrolled"), do: "That device already holds a passkey for this site."
defp message("no_challenge"), do: "That took too long. Start again."
defp message("malformed_credential"), do: "Your browser sent something this site cannot read."
defp message("not_discoverable"), do: "That device will not store a passkey this site can find."
defp message("unknown_credential"), do: "That passkey is not one this site knows."
defp message("no_attested_credential"), do: "Your browser sent no passkey to store."
defp message("credential_id_too_long"), do: "That passkey is bigger than this site can store."
defp message("verification_failed"), do: "That did not check out. Start again."
defp message("ceremony_failed"), do: "Your browser stopped partway through."
defp message("recovery_failed"), do: "Recovery did not finish. Please try again."
defp message("unknown"), do: "That request failed without saying why."
defp message(other), do: "Something went wrong: #{other}"
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<div :if={@error} class="alert alert-error"><span>{@error}</span></div>
<form phx-change="validate" phx-submit="register">
<.input name="username" value={@username} label="Username" required placeholder="ada_lovelace" />
<.button variant="primary">Register</.button>
</form>
<.button phx-click="sign-in" class="btn mt-4">Sign in with a passkey</.button>
<form phx-submit="recover" class="mt-8">
<.input name="code" value="" label="Lost your passkey? Use a recovery code" required />
<.button class="btn">Sign in with a code</.button>
</form>
<div
id="passkey"
phx-hook="Ithibati.Web.Hooks.PasskeyCeremony"
data-registration-challenge-url={~p"/auth/registration/challenge"}
data-registration-url={~p"/auth/registration"}
data-authentication-challenge-url={~p"/auth/authentication/challenge"}
data-authentication-url={~p"/auth/authentication"}
data-recovery-url={~p"/auth/recovery"}
>
</div>
</Layouts.app>
"""
end
endThe hook reads its endpoint URLs from the empty div. push_event/3 starts the exchange;
ithibati:failed returns an error code for your page to explain. The validate event in this
example only retains the username; the handler performs validation before issuing a challenge.
Create the protected page at lib/my_app_web/live/inside_live.ex:
defmodule MyAppWeb.InsideLive do
use MyAppWeb, :live_view
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash}>
<p>
Signed in as <strong>{@current_account.username}</strong>
— <.link href={~p"/session"} method="delete" class="link">sign out</.link>.
</p>
</Layouts.app>
"""
end
end10. Check it, then run it
$ mix ithibati.doctor
$ mix phx.server
Open http://localhost:4000 and try the complete flow:
- Register a username and passkey.
- Save the recovery codes and follow Done to
/inside. - Sign out, then open
/insidedirectly. It should redirect to/. - Sign in with the passkey. You should return to
/inside. - Sign out and sign in with one recovery code.
- Sign out and try that same code again. It should be refused.
The doctor checks setup; this browser pass checks the pages, assets and redirects as well. See Setup checks if configuration or migrations fail.
Before deploying, set the endpoint's public URL as described in The relying party, and add application-level rate limiting to sign-in and recovery requests.
Where to go next
- Configuration and schemas: identifiers, indexes, table names and migration upgrades.
- Registering and signing in: callbacks, browser events, sessions and direct ceremonies.
- Passkeys: enrol and manage additional credentials.
- Recovery codes: issue, redeem and replace codes.
- Invitations and the first account: restrict who can register.