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
end

ithibati_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
end

In *_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)
end

Keep 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
end

registration_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: "/")
end

The 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/*"
end

Create 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
end

The 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
end

The 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
end

10. Check it, then run it

$ mix ithibati.doctor
$ mix phx.server

Open http://localhost:4000 and try the complete flow:

  1. Register a username and passkey.
  2. Save the recovery codes and follow Done to /inside.
  3. Sign out, then open /inside directly. It should redirect to /.
  4. Sign in with the passkey. You should return to /inside.
  5. Sign out and sign in with one recovery code.
  6. 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