A passkey ceremony has four steps: request a challenge, ask the authenticator to answer it, post the credential, and verify it. Ithibati handles these steps; your application decides who may register and what to do with the verified account.
For a complete application, start with Getting started. This page is the reference for routes, callbacks, browser events and sessions. The final section shows the core calls without Phoenix.
The routes
Import Ithibati.Web.Router in your router and mount the endpoints in a JSON pipeline:
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| Endpoint under this scope | Purpose |
|---|---|
POST /auth/registration/challenge | Approve a registration subject and issue its challenge |
POST /auth/registration | Verify the credential and call register/4 |
POST /auth/authentication/challenge | Issue a challenge without naming a credential |
POST /auth/authentication | Verify the assertion and call authenticate/2 |
POST /auth/recovery | Redeem a code and call recovered/3 |
The scope prefix is yours; the five suffixes are fixed. The pipeline must accept JSON, fetch
the session and apply CSRF protection. The hook supplies the x-csrf-token header from the
page's CSRF meta tag.
The hook sends Accept: application/json. A pipeline with plug :accepts, ["html"] rejects
that request with HTTP 406 before it reaches the ceremony controller. Check the pipeline on
the mounted scope if the browser reports this response.
Mount options are handler:, rp_name:, user_verification: and seconds:. User verification
defaults to "preferred"; challenge lifetime defaults to sixty seconds. Set
user_verification: "required" if your application requires authenticator user verification.
The handler
Implement Ithibati.Web.Handler and name the module in ithibati_routes/1.
| Callback | Return on success | Application decision |
|---|---|---|
registration_subject(conn, params) | {:ok, identifier_or_account} | Who may start registration |
register(conn, key_attrs, subject, params) | {:ok, conn} | How to store the verified credential |
authenticate(conn, account) | {:ok, conn} | What a successful sign-in issues |
recovered(conn, account, fresh) | {:ok, conn} | How to sign in and display any replacement codes |
All four callbacks may return {:error, reason}. Atom reasons become error-code strings in
JSON responses; other reasons are reported as verification_failed.
For a new account, registration_subject/2 should validate and normalize the identifier with
your changeset before returning it. For another passkey on an existing account, return the
signed-in account itself; that lets Ithibati populate excludeCredentials. See
Adding a passkey.
register/4 receives the subject approved at the challenge step. Use that subject when
building the account. The browser posts parameters again, so an identifier or invitation token
in the second request is not evidence of what was approved in the first.
Verification issues no session by itself. A typical handler creates one explicitly:
@impl true
def authenticate(conn, account),
do: {:ok, conn |> Gate.log_in(account) |> json(%{redirect: "/inside"})}Here Gate aliases Ithibati.Web.Gate and json/2 is imported from Phoenix.Controller.
Return a JSON redirect field for the hook to perform a full page load. A controller HTTP
redirect is not equivalent: the hook expects JSON. Other successful response bodies are sent
to the LiveView as ithibati:done.
recovered/3 receives nil or a fresh recovery-code batch as its third argument. Do not drop
that batch; Recovery codes shows the two branches.
The JavaScript
Add an element with a stable ID and the endpoint URLs for the exchanges the page starts:
<div
id="sign-in"
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>| LiveView event to push | Payload |
|---|---|
ithibati:register | Registration parameters, such as %{username: username} or %{token: token} |
ithibati:authenticate | %{} |
ithibati:recover | %{code: code} |
The hook sends registration parameters with both requests. Authentication does not ask for an identifier: the browser lets the person select a discoverable passkey.
Handle ithibati:failed for errors and ithibati:done for successful responses without a
redirect. The hook uses HTTP requests to let the controller update the session cookie.
Browser imports
With the Hex dependency and Phoenix 1.8's generated esbuild setup, import the package in
assets/js/app.js and merge its hooks into the existing socket options:
import {hooks as ithibatiHooks} from "ithibati"
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ...ithibatiHooks},
})Keep the generated Socket, LiveSocket, csrfToken and colocatedHooks definitions, and
any other hooks or options your application uses.
For a local path dependency, Mix does not create deps/ithibati. Add an esbuild alias that
points from the configured asset working directory to the library file. For sibling application
and library directories, with esbuild running from the application's assets/, this is:
--alias:ithibati=../../ithibati/priv/static/ithibati.jsFor the colocated manifest, set ITHIBATI_COLOCATED_HOOKS=1 in the build environment and
recompile the dependency with mix deps.compile ithibati --force. Then use:
import {hooks as ithibatiHooks} from "phoenix-colocated/ithibati"The environment-variable change alone does not make Mix rebuild the dependency. Both imports register the same hook name.
For browser code without LiveView, the package also exports register and authenticate.
They take WebAuthn options, call the browser credential API and return the credential data to
post to your server. They do not provide your application's HTTP transport.
The relying party
The web layer derives the default relying-party ID and origin from your endpoint's configured
:url. Configure the public URL, including when a proxy terminates TLS:
config :my_app, MyAppWeb.Endpoint,
url: [host: "auth.example.com", scheme: "https", port: 443]This produces rp_id: "auth.example.com" and origin "https://auth.example.com".
The RP ID contains no scheme or port. For an ordinary browser deployment, use the page's
host or a registrable parent domain: auth.example.com can use example.com, but not com
or www.example.com. www. is a distinct subdomain, not an interchangeable spelling.
Cross-domain use requires the separate
related-origin mechanism, where supported;
adding an origin to the server's trusted list does not enable it in the browser.
Keep the relying-party ID stable: existing passkeys are bound to the ID used at registration. A different ID requires enrolment for that ID; changing a redirect does not migrate credentials.
The optional relying_party/2 handler callback receives the defaults. Use it to select values
for additional trusted clients, for example by adding an application-defined list of origins:
def relying_party(_conn, {rp_id, origin}), do: {rp_id, [origin | @extension_origins]}Choose origins from a fixed trusted set. Never reflect the request's origin header into this
return value: it lets the requester choose which origin the server trusts. This removes the
server's origin allowlist as a phishing defense, even though signature and RP-ID checks still
apply. For example, sharing an RP ID across subdomains does not make every subdomain trusted.
Support for an extension or native client also depends on that client's WebAuthn integration.
The core receives both values per call. Setting config :wax_, origin: ... or rp_id: ...
does not configure Ithibati.
Who is signed in
Ithibati.Web.Gate reads the session and assigns @current_account on a connection or LiveView.
| Mode | Behaviour |
|---|---|
:current_account | Assign the account or nil, then continue |
:require_account | Refuse access when there is no account |
A plug in :require_account mode redirects when given to: and otherwise returns 401.
A LiveView mount in that mode requires to::
live_session :members,
on_mount: [{Ithibati.Web.Gate, {:require_account, to: "/"}}] do
live "/inside", InsideLive
endThe gate authenticates sessions. Your application checks permissions and handles any bearer-token authentication separately.
Signing in and out
Gate.log_in(conn, account) renews the session and stores a new session token. It clears the
old session contents and CSRF token, so complete sign-in with a full page load.
Gate.log_out(conn) revokes that token and clears the session. With the endpoint's PubSub and
LiveView socket configured, it also disconnects sockets opened by that session. Keep the
generated socket's session connection information:
socket "/live", Phoenix.LiveView.Socket,
websocket: [connect_info: [session: @session_options]]The endpoint must have a :pubsub_server. Without those pieces, new requests lose access but
already-connected LiveViews are not disconnected by logout.
Session storage and expiry
The gate uses these core calls:
alias Ithibati.Identity.Sessions
Sessions.generate_session_token(account)
Sessions.get_user_by_session_token(token)
Sessions.delete_session_token(token)Issuance returns the plaintext token; the row stores its SHA-256 digest. Lookup returns an
account or nil for a missing, unknown, revoked or expired token. Revocation returns :ok.
Sessions last sixty days by default; Configuration describes
session_validity.
Sign out everywhere
Gate.log_out_all(conn) revokes all stored sessions of the account authenticated by the current
session token, clears this browser's cookie session and broadcasts a LiveView disconnect for each
revoked session. Use it in a CSRF-protected controller action, followed by a redirect:
def sign_out_all(conn, _params) do
conn |> Ithibati.Web.Gate.log_out_all() |> redirect(to: "/")
endThe endpoint and socket need the same PubSub and connect_info configuration as single-session
logout. A missing, unknown or expired token only clears the current browser session. Connection
assigns cannot select a different account. Call this helper outside a database transaction, so
broadcasts follow commit; an outer transaction raises before revocation.
For application-owned revocation, Sessions.revoke_all(account) returns the stored token digests
of the deleted sessions, including expired ones, or [] when there are none. It joins a caller's
transaction and does not broadcast. Defer external notifications until that transaction commits.
Do not pass these digests to Gate.live_socket_id/1, which expects a plaintext token.
Revocation does not disable an account: it can sign in again, and concurrently created sessions may survive. Database errors propagate without retries. PubSub delivery and database commit are separate; a delivery failure does not restore revoked sessions. Only sockets belonging to sessions still stored in the database can be reached through this operation.
Session cleanup
Sessions.delete_expired() deletes expired session rows across all accounts and returns the
number deleted. It uses the configured session_validity; invalid settings raise before deletion.
Run it from an application-owned scheduled job or maintenance task. Ithibati starts no scheduler.
Expiry is already enforced during lookup, so cleanup is storage maintenance. It does not disconnect LiveViews that are already connected. After cleanup those sessions no longer have stored digests for a later logout broadcast; applications requiring a hard time limit on connected LiveViews must also enforce expiry while connected.
Challenge storage
The Phoenix endpoints retain each challenge and its approved subject and mount settings in the session. They also store its digest and expiry in the database. Before checking any response, the endpoint atomically deletes that record, so an old cookie or a parallel request cannot reuse the challenge. A failed verification also consumes it. Issuing another challenge in the same session slot invalidates the previous one; an in-flight ceremony may need to restart.
Apply schema version 2 before using these endpoints. Consumption raises if the entire endpoint request is wrapped in an application transaction, since rollback could restore the record. Transactions inside handler callbacks are fine: consumption has already committed before a callback runs.
Every issued challenge writes a database row, including challenges requested before sign-in. Apply rate limiting to challenge issuance in your application or gateway to bound write load and storage growth. Ithibati defaults to a 60-second challenge lifetime; expiry prevents verification but does not delete the row. Abandoned rows remain until cleanup runs.
Schedule Ithibati.Identity.Challenges.delete_expired/0 in your application's maintenance job
to remove abandoned challenges. Consumed rows are removed immediately. Expired rows never
authorize verification, even before cleanup. Direct core integrations still own challenge
storage and may use Ithibati.Identity.Challenges for the same database-backed consumption.
Handling failures
Controller failures return a JSON error string and a 4xx response. Browser failures use the
same vocabulary. The hook delivers both as ithibati:failed; your application supplies the
message shown to the person.
| Code | Meaning |
|---|---|
no_credentials | No passkey is registered on this instance |
no_challenge | No matching stored challenge is available |
malformed_credential | The posted credential has an unexpected shape |
not_discoverable | The credential was reported as not discoverable |
unknown_credential | The credential is not stored on this instance |
no_attested_credential | The attestation contains no credential to store |
credential_id_too_long | The credential ID exceeds the storage limit |
already_enrolled | The browser excluded this credential, or it already exists in storage |
invalid_code | A recovery code is unknown or already spent |
verification_failed | Verification or a handler returned a reason without a dedicated atom code |
ceremony_cancelled | The browser reported NotAllowedError, including cancellation or timeout |
ceremony_failed | Another browser or request failure prevented completion |
recovery_failed | The recovery exchange did not complete successfully in the browser |
unknown | The hook had neither an error code nor a status to report |
A failed network response does not prove the server did no work. In particular,
recovery_failed does not guarantee that the recovery code remains unused.
Two families carry a suffix: http_<status> for an unexpected HTTP response and
missing_data_<attribute> for a missing hook URL. Handle them by prefix or with a fallback.
Your handler can return additional atom codes.
Ithibati.Ceremony.codes/0 lists the fixed codes and Ithibati.Ceremony.families/0 lists the
families. Use them to check that your application's messages cover the library's vocabulary.
The event also carries exception, the browser's DOMException name or nil. Preserve it
for diagnosis. For example, SecurityError can point to a relying-party or origin mismatch.
InvalidStateError maps to already_enrolled during registration and ceremony_failed during
authentication.
Without the web half
Call Ithibati.Identity.Passkeys directly when you provide the transport and challenge storage.
For registration on an existing account:
alias Ithibati.Identity.Passkeys
challenge = Passkeys.registration_challenge(rp_id, origin, user_verification: "preferred")
options = Passkeys.registration_options(challenge, account, rp_name: "MyApp")Send options to the client and retain the challenge and approved subject. When the credential
returns, consume the stored challenge and verify it:
with {:ok, key_attrs} <- Passkeys.verify_registration(credential, challenge) do
Passkeys.add_key(account, key_attrs)
endFor a new account, approve an identifier and pass it to registration_options/3 instead of an
account. After verification, insert the account and append Grant.with_key_and_codes/3 in a
single transaction, as shown in Getting started.
For authentication, start with:
case Passkeys.authentication_challenge(rp_id, origin) do
{:ok, challenge} ->
options = Passkeys.authentication_options(challenge)
{challenge, options}
{:error, :no_credentials} ->
{:error, :no_credentials}
endAfter the client responds, consume the stored challenge and call:
Passkeys.verify_authentication(credential, challenge)That returns {:ok, account} or {:error, reason}. Your integration must:
- Retain the challenge and approved subject between requests, and consume the challenge once, including after a failed verification. A signature on stored data alone does not prevent replay.
- Keep registration and authentication challenges separate and bind them to the intended flow.
- Supply a trusted relying-party ID and origin on every challenge call.
- Decide whether to issue a session or an application-owned credential after verification.
These fragments show the core calls; they are not a complete transport or challenge store.