Registering and signing in

Copy Markdown View Source

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 scopePurpose
POST /auth/registration/challengeApprove a registration subject and issue its challenge
POST /auth/registrationVerify the credential and call register/4
POST /auth/authentication/challengeIssue a challenge without naming a credential
POST /auth/authenticationVerify the assertion and call authenticate/2
POST /auth/recoveryRedeem 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.

CallbackReturn on successApplication 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 pushPayload
ithibati:registerRegistration 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.js

For 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.

ModeBehaviour
:current_accountAssign the account or nil, then continue
:require_accountRefuse 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
end

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

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

CodeMeaning
no_credentialsNo passkey is registered on this instance
no_challengeNo matching stored challenge is available
malformed_credentialThe posted credential has an unexpected shape
not_discoverableThe credential was reported as not discoverable
unknown_credentialThe credential is not stored on this instance
no_attested_credentialThe attestation contains no credential to store
credential_id_too_longThe credential ID exceeds the storage limit
already_enrolledThe browser excluded this credential, or it already exists in storage
invalid_codeA recovery code is unknown or already spent
verification_failedVerification or a handler returned a reason without a dedicated atom code
ceremony_cancelledThe browser reported NotAllowedError, including cancellation or timeout
ceremony_failedAnother browser or request failure prevented completion
recovery_failedThe recovery exchange did not complete successfully in the browser
unknownThe 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)
end

For 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}
end

After 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.