I have released the first version of Ithibati, a passkey-authentication library for Elixir. It came out of my own blog engine, where the accounts context had grown into the one part I did not want to write a third time.
Swahili, ithibati: proof, evidence. In WebAuthn’s own vocabulary, attestation.
Why another one
Most authentication libraries answer two questions at once: Who is this, and what may they do? The second answer is different in every application - sites, tenants, teams, roles, invitations - which is why the first one so rarely gets reused. Ithibati answers only the first. It knows the account row, its passkeys, its recovery codes, and its sessions, and it knows nothing about what that account may then do.
You keep your own users table. The library contributes a schema macro and the changeset pieces that validate the field an account is known by; it never assumes a column the macro did not put there.
What using it looks like
Five touchpoints, and they are close to the whole surface.
# mix.exs
{:ithibati, "~> 0.1"}
One line in your account schema names the field an account is known by:
use Ithibati.Schema.User, identifier: :username, format: Identifier.username_format()
One line in your router mounts the ceremony endpoints:
ithibati_routes handler: MyAppWeb.Auth, rp_name: "MyApp"
Your handler decides what a verified assertion is worth. Ithibati hands you the account and issues nothing of its own:
@impl true
def authenticate(conn, account),
do: {:ok, conn |> Gate.log_in(account) |> json(%{redirect: "/"})}
And a page starts a ceremony by pushing an event to the browser hook:
def handle_event("sign-in", _params, socket),
do: {:noreply, push_event(socket, "ithibati:authenticate", %{})}
There is no password field anywhere and no sign-in form either. A sign-in challenge names no credentials, so the browser offers whichever passkeys it holds for your site, and the person picks one. Registering is the half that still needs a form because the name does not exist yet.
What is in the box
- Passkey registration and authentication through
wax_, including the first account on an empty instance. - Single-use recovery codes, twelve by default, which refill themselves when the last one is spent. There is an endpoint for signing in with one.
- Revocable server-side sessions. The cookie carries the secret, the row carries its sha256, and signing out revokes the row rather than forgetting it. It also ends the LiveViews that session opened, when your endpoint has a pubsub server.
- Invitations, optional: Ithibati owns the token, its expiry, and the redemption, and you own the table and whatever the invitation grants.
- Composable
Ecto.Multifragments, so creating an account, its first passkey and its recovery codes is one transaction that you hang your steps on. mix ithibati.doctor, which asks twelve questions about a setup and says which one is wrong.- Three Credo checks a consuming project can switch on, if you want the boundaries enforced rather than merely written down.
The Phoenix half - the routes, a gate, a LiveView hook - is optional, and phoenix,phoenix_live_view and plug are optional together. Without them you still get a core that compiles, and a ceremony is a handful of function calls. priv/static/ithibati.js exportsregister and authenticate as plain functions, so a browser extension or a native client can use the same path.
What it deliberately does not do
- API tokens. What makes one is the scopes, the rotation, the expiry chosen per token, the page where somebody sees and revokes theirs, and the audit trail. None of that is here. A session is the only credential the table holds.
- Send mail. An application that mails an invitation link itself has proved the address by the time the account exists, because accepting an invitation refuses an account created under any other identifier. That is the whole of the address verification on offer.
- Decide what an account may do. Roles, tenancy, and memberships stay yours.
The small print
It is a first release, and Elixir 1.17 and up.
It needs Postgres today. Two places read the database catalogue directly, which is what lets the migration and mix ithibati.doctor tell you which column or index your own table is missing rather than failing later with an undefined_column. The races are settled with a FOR NO KEY UPDATE row lock. Whether either of those has to stay a requirement is a question I have not finished answering, and a single-user instance on SQLite is the case I keep coming back to.
There are two example applications in the repository, one for open registration and one for invitation-only, and CI drives both in a real browser so they cannot quietly rot. Read those when the guides and your editor disagree.
- Docs: Ithibati v0.1.0 — Documentation
- Source:
I would rather hear that a decision is wrong now than after somebody has built on it, so criticism of the shape is as welcome as a bug report.


















