This page walks an empty Phoenix application from mix phx.new to a working sign-in: somebody
registers with a passkey, writes down their recovery codes, lands on a page only accounts can
see, and signs out again. Take the same steps in your own project and the shape is the same.
You need Elixir 1.17 or newer and Postgres. Postgres is a requirement, not a default: the migration reads your account table's catalogue entries to check what it is about to point a foreign key at, and no other adapter answers those questions.
For the web half, Phoenix 1.8 and LiveView 1.1. Everything from step 7 on assumes them —
Layouts.app, the <.input> and <.button> components, the colocated-hooks import, and deps
being on esbuild's NODE_PATH, which is what makes the bare "ithibati" specifier resolve. The
context half needs neither.
$ mix phx.new my_app
$ cd my_app
$ mix ecto.create
Both example applications in the repository do everything below, and CI drives them in a real browser. If you would rather read finished code, start there — this page is the open-registration one, spelled out.
1. Add the dependency
def deps do
[{:ithibati, "~> 0.1"}]
endPhoenix, LiveView and Plug are optional dependencies, so a phx.new application already has
everything the web half needs. Leave them out and you get the context on its own; Registering
and signing in shows the ceremony as plain function calls.
2. Configure it
config :ithibati,
repo: MyApp.Repo,
user_schema: MyApp.Accounts.User,
users_key_type: :idPut this in config/config.exs, above the import_config line at the bottom — that line has to
stay last, and users_key_type and table_prefix are read at compile time, so neither can live
in config/runtime.exs.
users_key_type is the primary-key type of your users table, and Ithibati's foreign keys use it.
:id is what mix phx.new generates; write :binary_id if you asked for binary ids, which is
also Ithibati's own default. If the table disagrees with what you configured, the migration
refuses rather than building something that cannot be referenced — users.id is bigint against a
configured :binary_id is the message you get.
3. The account schema
You keep your own account schema and your own table. Two lines connect it to Ithibati, and both
generate code you will not see in the snippet below. use Ithibati.Schema.User records which
field identifies an account and adds identifier_changeset/2.
ithibati_account/0 declares that
field and the passkeys, recovery_codes and sessions associations.
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
endThe identifier is yours to choose
There is no default, because a library that never sends mail has no business insisting on an email address. Pass the field an account is known by, and a pattern if you want one:
use User, identifier: :email, format: Identifier.email_format()
use User, identifier: :handleThis walkthrough uses a username, because that is what Ithibati can support end to end. It
cannot prove that somebody owns an email address — it sends no mail — so an application keyed by
an address is trusting whatever was typed in. That is a normal thing to do, and it is why
email_format/0 ships; it is just not what a first
walkthrough should quietly assume.
format: is the regex the identifier has to match, and two come with the library. Leave the
option out and identifier_changeset/2 still trims the value, downcases it and requires it — it
just does not check a shape.
username_format/0 is Mastodon's rule for a
local account: letters, digits and underscores, at
most thirty characters. Their looser pattern, the one that allows dots and hyphens, is for
addressing accounts on other servers rather than naming your own. It is strict on purpose: dots
and hyphens would let alice.smith register next to alicesmith, and characters outside ASCII
would let a Cyrillic а register next to a Latin a. Both pairs look identical on screen.
email_format/0 is the pattern the HTML specification publishes for <input type=email>, not
RFC 5322. An identifier here is a credential rather than a mailbox, so the full grammar — quoted
local parts with spaces in them — would be a hazard rather than a feature. It accepts
you@localhost and refuses "a b"@example.com.
Your own pattern is as welcome as either of them, written inline or named:
@handle ~r/\A[a-z][a-z0-9_]{2,29}\z/
use User, identifier: :handle, format: @handlePut the attribute above the use line, because that is where your module body reads it. An
attribute that does not exist yet, or one you misspelled, is nil, and Ithibati refuses
format: nil instead of quietly falling back to no pattern at all. identifier: is the one
option you cannot write as an attribute: it has to be a literal atom, because it names the field
your schema declares and belongs where you can read it.
identifier_changeset/2 trims and downcases what it writes, so a plain unique index already
refuses AdaLovelace beside adalovelace and you do not need a functional index. A write that
bypasses the changeset stores whatever it is given.
Should the identifier column be citext?
You do not need it. Lowercasing happens before anything reaches the database, so an ordinary
unique index already does the job — and citext is an extension, which CREATE EXTENSION citext needs rights for that a hosted database does not always grant.
They are also not the same thing, and the difference is what ends up stored. citext keeps
what somebody typed and compares regardless of case; lowercasing keeps only the lowered
value. So if you want AdaLovelace on a page, that is display: put it in a field of your own,
or define passkey_display_name/1 as shown below.
Choose citext anyway when the reason is yours rather than Ithibati's — writes that bypass
the changeset, or rows already in mixed case. Then pass unique_index: false and build the
index yourself, which is what that option is for.
If a passkey dialog should show something friendlier than the identifier, define this:
def passkey_display_name(account), do: account.nameYou do not need a fallback. An account that has not filled it in returns nil, and Ithibati
shows the identifier instead.
4. The migration
A phx.new application has no users table — priv/repo/migrations/ is empty — so you write
that one first. It is entirely yours; Ithibati only needs the identifier column to be in it:
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:name is there because the schema in step 3 declares it; drop it if yours does not. The
timestamps match that schema, which uses :utc_datetime_usec — Ithibati's own tables do too, and
Ecto raises if a schema and its table disagree about the type.
Then Ithibati's own migration, which you write and it fills in:
defmodule MyApp.Repo.Migrations.AddIthibati do
use Ecto.Migration
def up, do: Ithibati.Migration.up(version: 1)
def down, do: Ithibati.Migration.down(version: 1)
endPin the version, as above. An unpinned call creates a different set of tables depending on when it runs, and rolls back neither. Run it after the migrations that create your own tables, because it points foreign keys at your accounts table.
It creates ithibati_keys, ithibati_recovery_codes, ithibati_sessions and
ithibati_bootstrap, each with a foreign key to your account table. It also creates the unique
index on your identifier column, because a changeset cannot keep an identifier unique against a
concurrent insert. Only the database can.
Deleting an account takes its passkeys, its recovery codes and its sessions with it —
on_delete: :delete_all — so your own delete path needs no cleanup here. The bootstrap row is
the exception: its user_id is nilified rather than the row removed, because what it records is
that this instance was set up, and that stays true after the account that did it is gone.
The identifier column itself is yours. Add it in the create table that builds your users table,
or in an alter if that table already exists:
alter table(:users) do
add :username, :string, null: false
endWait — why does Ithibati index this column but not create it?
Because a rollback can take an index back and cannot take a column back.
down/1 drops the
index and your rows are untouched; dropping the column would take the identity of every
account with it, in a table that is yours. So Ithibati never creates one, and never has to
decide whether to drop it.
Two smaller reasons sit on top. It could not guess the type — citext is a normal choice
here — and null: false cannot be added to a table that already has rows without a default or
a backfill, so the column it made would be missing the one constraint you wanted.
The index is worth the asymmetry because nothing else can keep the identifier unique: two registrations at the same moment both pass the changeset and both insert. A forgotten index is not a migration that fails, it is the day two accounts answer to one name.
Forget the column and the migration says so before it builds anything:
** (ArgumentError) MyApp.Accounts.User declares users.username, and the table has no such
column. The column is yours to add — in the migration that creates the table, or in one of its
own — and this library creates the unique index on it.Before it builds anything, the migration checks users_key_type against your accounts table: the
column its foreign keys point at has to exist, have the type you configured, and carry a unique
index. That last one is what Postgres requires of any referenced column, so a composite primary
key with a unique index beside it is fine. A disagreement is refused, not half-applied.
Then mix ecto.migrate.
Saying how the identifier index is built
Ithibati builds that index while the migration runs, but you steer it from the use line in your
schema rather than from the migration. Two options do it, and both may be module attributes, like
format:.
Ecto derives the index name from the table and the column. If that is not your convention, name it yourself and Ithibati creates it under that name:
use User, identifier: :username, constraint_name: :users_username_uniqIf you would rather build the index yourself — a partial index, an expression, citext, a
composite with a tenant column — say so, and Ithibati stops creating one. It then checks that a
unique index covering that column exists and refuses the migration if it does not:
use User, identifier: :username, unique_index: falseA later release that adds tables
The schema is versioned, and version: 1 is what this release builds. When a later release of
Ithibati adds to it, the changelog says so and names the new number;
Ithibati.Migration.current_version/0 answers with the one your installed copy knows. Until that
happens there is nothing to do — the migration you already wrote stays pinned at 1 and does not
run again.
Upgrading is a second migration of your own, saying where it starts:
def up, do: Ithibati.Migration.up(from: 1, version: 2)
def down, do: Ithibati.Migration.down(from: 1, version: 2)You never write the SQL by hand, and Ecto records what has been applied in your own
schema_migrations.
5. The handler
This is the module Ithibati asks whenever it reaches a decision it does not make: who may
register, what an account is made of, what a successful ceremony issues, and what happens when
somebody signs in with a recovery code instead. Four callbacks, and Ithibati.Web.Handler
documents each one.
It lives under MyAppWeb rather than in the context, because every callback takes a conn and
hands one back — it sets the session and answers the request. It is the same place
phx.gen.auth puts MyAppWeb.UserAuth. What it does with the account, once a credential has
verified, is context work and can move into one of yours; this walkthrough keeps it inline so
the whole flow is on one page.
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: "/"})}
@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
endrecovered/3 is the one for somebody signing in with a recovery code instead of a passkey. Its two clauses are the whole of it: no fresh batch, sign them in as usual; a fresh
batch — which only happens when the code just spent was their last — show it once, on the page
step 6 builds. It is a separate callback rather than a flag on
authenticate/2 because that
batch is the only copy there will ever be, and a signature with nowhere to put it is a signature
that loses it.
Three more things in there are worth pausing on.
registration_subject/2 may answer with the
identifier or with the account itself — here it is
the identifier, because the account does not exist yet. Hand back an account when there is one
(enrolling a second device), and
registration_options/3 can then list its
existing credentials
in excludeCredentials, so a browser does not offer an authenticator that is already enrolled.
It is asked before a challenge is minted, which is why an identifier your schema could never store is refused there. Approving it would mean a passkey dialog, a credential the authenticator then keeps, and a refusal only after all of that. It asks the changeset rather than repeating the pattern, and answers with the normalised value, so the name on the dialog is the name that gets stored.
register/4 composes.
Grant.with_key_and_codes/3 appends the passkey
and the recovery codes
to the transaction that creates the account, so an account never exists without a way back in.
Your own steps — a membership row, a default project — go into the same Multi and commit or
roll back with it.
It reads the account from the step that made it, expecting one called :account; pass
account: :person if yours is named something else. It adds two steps of its own, :passkey and
:recovery_codes, so do not use those names — the second is the one you destructure to show the
codes.
Its error branch asks Ithibati.Schema.User.identifier_taken?/1, because a name somebody else
already has and a name that is not allowed both arrive as {:error, changeset} and need
different words on screen. Written by hand, that check is a scan for constraint: :unique, which
also matches your own unique columns — a slug, a tenant key — and reports a collision on one of
those as the address being taken. Ithibati named the identifier field, so it knows which error
belongs to it. Ask after the insert: a constraint error only exists once the database has refused
a write.
Both register/4 and authenticate/2 answer with a redirect. Signing in renews the session and
clears the CSRF token with it, so the flow has to end in a full page load, and the hook follows
that redirect.
6. Showing the recovery codes
The handler put the codes in the session and redirected to /recovery-codes. That page is the
only time anybody will see them: the rows hold sha256 digests, and nothing recovers the plaintext
afterwards.
Why a controller and not a LiveView?
The session key has to be gone once the page has been read, and a LiveView has no connection to delete it from. A refresh would show the codes again, which is the opposite of showing them once.
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: "/")
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<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>The database holds only their digests. Put these somewhere safe now.</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 navigate={~p"/"} class="link">Done</.link></p>
</Layouts.app>render/3 needs a view module, which phx.new did not generate for this controller. Put it in
lib/my_app_web/controllers/session_html.ex:
defmodule MyAppWeb.SessionHTML do
use MyAppWeb, :html
embed_templates "session_html/*"
endThe template belongs at lib/my_app_web/controllers/session_html/recovery_codes.html.heex.
Leaving the module out compiles cleanly and passes mix ithibati.doctor — it only fails at the
first successful registration, when somebody has already created a passkey and their codes have
already been issued.
Twelve codes by default, each good for one sign-in. Spending the last one issues a fresh batch in
the same transaction, because an account with no passkey and no codes left is locked out for
good. Recovery codes covers redeeming one, count:, and how to turn the refill
off.
7. The routes
Four pieces go into the router phx.new generated: the import, the gate in :browser, a second
pipeline, and the scopes. Everything the generator put there — get "/", PageController, :home,
the dev_routes block with LiveDashboard and the mailbox preview — stays. Shown whole so the
order is visible:
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
endWhy a second pipeline instead of :browser?
These endpoints answer JSON, and :browser's accepts ["html"] refuses the hook's
request with a 406 before the controller is ever reached. The other two plugs are load-bearing
as well: the challenge waits in the session between the two round-trips, and the hook answers
protect_from_forgery with an x-csrf-token header, which a JSON body is not exempt from.
The routes has the detail.
Ithibati.Web.Gate answers who is signed in, in a pipeline and in a live_session, from one
module. :current_account assigns @current_account or nil and always continues.
:require_account refuses when there is nobody, and needs :to in a live_session, because a
LiveView that halts with nowhere to send a person is a dead end.
Signing out is a plain delete, for the same reason the recovery codes are a controller: a
LiveView cannot clear a session cookie.
8. The browser
The ceremonies need client code, because the browser's API wants buffers where Ithibati sends
unpadded base64url, and it hands back a credential that has to be serialised the way the
verifications expect. Ithibati ships it, and the specifier is bare because a Phoenix 1.8
application already has deps on esbuild's NODE_PATH, where a Hex dependency lives.
That last part is why a path dependency needs one more argument: Mix creates no
deps/ithibati for one, so there is nothing on NODE_PATH to resolve the name against and
esbuild stops with Could not resolve "ithibati". Point it at the file instead, as another
--alias in the esbuild arguments phx.new generated in config/config.exs:
config :esbuild,
version: "0.28.2",
my_app: [
args:
~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets/js
--external:/fonts/* --external:/images/* --alias:@=.
--alias:ithibati=../../ithibati/priv/static/ithibati.js),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => [Path.expand("../deps", __DIR__), Mix.Project.build_path()]}
]The alias path is resolved from cd:, which is your assets/ directory — so it is your path to
the library with assets/ climbed out of first. Both example applications carry exactly this
line, and say why in a comment beside it. Taking Ithibati from Hex needs none of it.
In assets/js/app.js:
import {hooks as ithibatiHooks} from "ithibati"
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ...ithibatiHooks},
})9. The page people register and sign in on
One page, two ceremonies. Registering is a form, because it needs a name. Signing in is a button.
Where is the login form?
There is not one, and this is the part that surprises people coming from passwords.
A sign-in challenge names no credential — allowCredentials is sent empty — so the browser
offers whichever passkeys it holds for your site and the person picks one. There is nothing to
type, so there is nothing to submit. A button is the whole interface, and it works on a device
where nobody has ever typed their username.
Registering is the other way round: the name does not exist yet, so you have to ask for it,
and registration_subject/2 is where your application approves it.
The LiveView says when a ceremony starts; the hook does the round-trips. A ceremony ends in a
session cookie and a LiveView cannot set one, so the hook posts to the endpoints over fetch and
follows the redirect your handler answered with.
phx.new generates no live/ directory, so mkdir lib/my_app_web/live first.
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}, socket),
do: {:noreply, assign(socket, error: message(error))}
def handle_event("ithibati:done", _payload, socket), do: {:noreply, socket}
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(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
endWhy does the registration form look like that?
It lives in a LiveView of yours, and here in the same one as the sign-in button, because one
page is shorter to walk through. Two separate routes work exactly as well — /register and
/sign-in, each with its own copy of the hook element below, and nothing else shared.
What is unusual is that there is no to_form/2 and no changeset, because phx-submit does
not send this form anywhere. It hands the value to the hook, which posts it to
/auth/registration/challenge and starts the ceremony; the form is never submitted in the
Phoenix sense. Validation still happens on the server, one step later:
registration_subject/2 is asked before a challenge is minted, and its refusal is what the
person sees.
phx-change="validate" is there so you can check the field while it is typed, before anybody
is shown a passkey dialog. This walkthrough only keeps the value; a changeset behind it works
the same way.
The empty <div id="passkey"> is the hook. It renders nothing, drives all three exchanges, and
reads
the endpoint paths off its own attributes, because you chose the scope they are mounted under.
Set the pair for each ceremony that page starts; a missing one is reported as
missing_data_registration_url rather than as a ceremony that failed.
And the page behind the gate, an ordinary LiveView that can now count on @current_account:
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
Twelve checks in one run, so you find out now rather than at the first sign-in. Then mix phx.server, open http://localhost:4000 and register: the browser asks for a passkey, you
create one, and you land on the recovery codes. Write one down. Sign out, sign back in with the
passkey, sign out again and use that code instead — both ways in work, and the loop is closed.
mix ithibati.doctor has the rest of what it asks.
Configuration
The three keys in step 2 are the ones every application sets. Everything else has a default, and you set it only when the default does not fit:
config :ithibati,
user_schema: MyApp.Accounts.User, # required — the module that uses Ithibati.Schema.User
repo: MyApp.Repo, # required — the repo this library reads and writes through
session_validity: {30, :day}, # default {60, :day} — how long a sign-in lasts
invitation_schema: MyApp.Accounts.Invitation, # optional — only if you use invitations
users_key_type: :id, # default :binary_id — compiled into the schemas
table_prefix: "auth" # default "ithibati" — compiled into the schemassession_validity is how long a sign-in lasts. The units are :second, :minute, :hour,
:day and :week; :month and :year are missing because neither has a fixed length. A value
this library cannot read is refused rather than guessed at.
users_key_type and table_prefix are read at compile time, so changing either recompiles
Ithibati. Elixir then refuses to boot against a value it was not built for, rather than looking
for a table nobody meant.
Where to go next
- Registering and signing in — the handler and the hook in full, the relying party, and the ceremony without Phoenix.
- Recovery codes — redeeming one, and what happens when the last is spent.
- Passkeys — enrolling a second device, and the one you cannot delete.
- Invitations, and the first account — the same application again, with registration closed to strangers. It picks up where this page leaves off.
- Ithibati — the map of every page, and the design decisions behind them.