Introducing Ithibati: passkey authentication for Elixir
Since July I've been working on Chapisho, a self-hostable blog engine written in Elixir and Phoenix. Its repository is still private. Like most things I build for myself, it needed a way to sign in. And as with Burrow, the Go web framework I wrote earlier this year, I didn't want passwords anywhere near it.
So Chapisho got passkey login early on. At some point I noticed that the identity part had very little to do with blogging. It knew about accounts, passkeys, sessions and invitations, and nothing about posts or themes. So I moved it into its own library, and that became Ithibati.
The name is Swahili and means proof or evidence. In WebAuthn's own vocabulary, that's attestation. Chapisho is Swahili too, for publication. I've liked the language since I studied computational linguistics and tried to speak it in a seminar. So my recent projects all have Swahili names: Chapisho, Ithibati, Sikio and Jibu.
Why passkeys, and why a library
I simply like passkeys. Passwords get forgotten and reused all the time. Since we switched to passkeys at home, my wife no longer uses the same password everywhere, and I count that as a real win.
A passkey is a key pair. The private half stays on the person's device, and my database only ever sees the public half. For a small self-hosted application, I like that a lot. I don't have to store any passwords, and nobody can reuse one.
Isn't that what phx.gen.auth is for?
Partly. phx.gen.auth is a very good generator, and the code it produces is yours to change. But it's built around email addresses, with magic links and optional passwords, and passkeys aren't part of it. I would have generated it and then replaced most of what it generated. I wanted the opposite: a library that does the WebAuthn ceremonies, the recovery codes and the sessions, and stays out of the rest of my application.
The WebAuthn verification itself isn't mine. Ithibati uses wax_ for that, and I'm grateful it exists. Ithibati adds everything around it, like storing the credentials, issuing and revoking sessions, and what happens when someone loses their phone.
What Ithibati does, and what it leaves to you
The design decision I care most about is that Ithibati has no opinion about what an account may do. It only answers the question who is this?
You keep your own account schema and your own users table. Ithibati adds the fields and associations it needs through a schema macro. You decide what identifies an account, whether that's a username, an email address or something else entirely. Roles, teams, memberships and permissions stay in your application.
Verification and issuing a credential are separate steps as well. After Ithibati has verified a passkey, it hands the account to a handler that you write. That handler decides whether to create a browser session, issue an API token or do something else first. The registration, sign-in and recovery pages are yours too. Ithibati supplies the endpoints and the browser integration, but not the HTML.
The web half is optional. The identity core works without Phoenix, as plain function calls, and there's a small JavaScript module for browsers that doesn't need LiveView. If you're on Phoenix 1.8 and LiveView 1.1, though, most of the wiring is already done.
What's in the box
- Passkeys. Registration and sign-in through WebAuthn. The browser offers the passkeys it has for your site, so nobody needs to type a username to sign in. People can add more passkeys later, rename them and revoke the ones they no longer use.
- Recovery codes. Single-use codes for the day a passkey isn't available. They're created together with the first passkey, so every account has a way back in from the start.
- Revocable sessions. Sessions live on the server. The browser holds a secret, and the database stores only its digest. Signing out deletes the session, and expiring sessions also disconnects any LiveViews still running on them.
- Invitations. Tokens with an expiry and a redemption step, for applications that aren't open to everyone. An invitation can name the person it's for, or leave the identifier for them to choose. An optional operator code guards the first account on an empty instance, so whoever deploys it can claim it before anyone else does. The first admin registers a passkey like everyone else, without a password or any special treatment.
- Ecto.Multi steps. Creating an account, its first passkey and its recovery codes happens in one transaction, together with whatever rows your application adds.
- A doctor.
mix ithibati.doctorchecks configuration, tables, indexes and handler wiring, and tells you what's missing. There are also Credo checks for code that uses Ithibati.
It runs on PostgreSQL, SQLite and MySQL. I love SQLite, and I believe it's more than enough for a lot of small applications. It's my default for the small and medium web applications I build. But I also like being able to switch to PostgreSQL for a larger installation, so Ithibati supports both.
Wiring it into a Phoenix app
The getting started guide walks a fresh Phoenix 1.8 application through every step. Here's a short overview, so you can see whether it suits you.
Do I have to do all of this by hand?
Not if you're starting a new application. Ithibati Starter is an Igniter installer that creates a fresh Phoenix app with Ithibati already wired in. You get passkey sign-in, invitation-only accounts with a setup code for the first one, recovery codes, revocable sessions, English and German screens, and the development tooling I use in my own projects. It writes ordinary application code, so the starter isn't a runtime dependency.
$ mix archive.install hex phx_new 1.8.14
$ mix archive.install hex igniter_new 0.5.34
$ mix archive.install github oliverandrich/ithibati-starter tag v0.7.0 --sparse installer
$ mix ithibati.new my_appIt is opinionated: vanilla Tailwind, Lucide icons and mise. It runs on SQLite by default, and one environment variable switches it to PostgreSQL.
If that isn't your setup, or you're adding passkeys to an application you already have, here's the manual route.
Add the dependency:
{:ithibati, "~> 0.8"}Your account schema stays as it is. Ithibati adds the identifier and its 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()
schema "users" do
ithibati_account()
field :name, :string
timestamps(type: :utc_datetime_usec)
end
endThe handler is where your application makes its decisions. Registration creates the account, its first passkey and its recovery codes in one transaction. Sign-in receives an account that has already been verified:
@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, _step, reason, _changes} ->
{:error, reason}
end
end
@impl true
def authenticate(conn, account),
do: {:ok, conn |> Gate.log_in(account) |> json(%{redirect: "/inside"})}In the router, one line mounts the registration, sign-in and recovery endpoints, and the gate protects your LiveViews:
scope "/auth" do
pipe_through :ceremony
ithibati_routes handler: MyAppWeb.Auth, rp_name: "MyApp"
end
live_session :members, on_mount: [{Ithibati.Web.Gate, {:require_account, to: "/"}}] do
live "/inside", InsideLive
endIn the browser, Ithibati's hooks join the ones Phoenix already generated:
import {hooks as ithibatiHooks} from "ithibati"
let liveSocket = new LiveSocket("/live", Socket, {
params: {_csrf_token: csrfToken},
hooks: {...colocatedHooks, ...ithibatiHooks},
})A sign-in button then only needs to push an event to that hook:
def handle_event("sign-in", _params, socket),
do: {:noreply, socket |> assign(error: nil) |> push_event("ithibati:authenticate", %{})}The guide covers the parts I've left out here: configuration, the migration, the session cookie and the page that shows the recovery codes. Once everything is in place, mix ithibati.doctor tells you whether it actually fits together.
The repository also has three complete example applications: open registration, invitation only and registration by email. CI builds all three, runs their tests and drives them through a real browser.
Where things stand
Ithibati has been on Hex since mid-September, and the current version is 0.8.1. That's fifteen releases in about a month. It's being used and fixed, and it's clearly not finished. Chapisho runs on it, and so does Sikio, a self-hosted media library for podcasts, YouTube and PeerTube that you can install today. Most of the recent changes came from using it in those two applications. One example: the session cookie used to disappear when the browser closed, while the server kept the session for sixty days.
If you'd like to see Ithibati in a real application, Sikio is the one to look at. Accounts there sign in with passkeys and join by invitation, and the first account claims the instance with an operator code. It runs on SQLite or PostgreSQL.
Until 1.0, the API may still change. Releases that touch the database schema say so in the changelog, together with the migration you need. The documentation has guides for every part I've mentioned here, and the code is MIT-licensed on GitHub.
It's a small library, but I'll use it in every new Phoenix project I start. If you try it in one of your own projects, I'd love to hear how it went, and especially where it got in your way.