Adding passkeys to a Phoenix app with wax_

Luca ·

The single most common way that a self-hosted app gets compromised is a password that should have been better. Even with TOTP enabled, the password is still the primary factor; TOTP just makes phishing one extra step harder.

Passkeys remove the password entirely. They’re phishing-resistant by construction (the credential is bound to the origin), they’re stored in the platform authenticator (Touch ID, Windows Hello, a hardware key), and the user experience — once it works — is genuinely better than typing a password.

So I added them to SlashFeed. The library that made it possible is wax_ by Matthieu Tanguay-Carel. This post walks through the integration, the parts the library handles for you, the parts you have to glue together yourself, and how I tested code that fundamentally needs a browser and a real authenticator to fully exercise.

What wax_ actually does

WebAuthn is roughly ten RFCs and W3C recommendations layered on top of each other: COSE keys, CBOR encoding, attestation formats, FIDO metadata services, the authenticator data binary blob, the relying-party verification algorithm. The protocol is excellent and the spec text is dense.

wax_ reads all of that for you. The public API is essentially three functions:

# Server-side, when starting a registration:
challenge = Wax.new_registration_challenge()

# Server-side, when starting a login:
challenge = Wax.new_authentication_challenge(allow_credentials: [...])

# Server-side, when finishing either:
{:ok, {auth_data, _attestation}} = Wax.register(attestation_obj, client_data_json, challenge)
{:ok, auth_data}                  = Wax.authenticate(cred_id, auth_data, sig, client_data, challenge, allow_credentials)

The challenge struct you get back is what you pass to the browser (well, the bytes inside it are). The browser then talks to the authenticator, gets back a credential, and POSTs it to your server. You hand the result to Wax.register/3 or Wax.authenticate/6 with the same challenge, and wax_ either returns {:ok, ...} or raises with a specific reason.

What wax_ does not do:

  • Persist credentials anywhere (that’s your Repo)
  • Build the JSON payloads the browser API expects (PublicKeyCredentialCreationOptions etc.)
  • Convert between base64url and raw bytes (you do that on both ends)
  • Decide what counts as a logged-in user once the credential is verified

The bulk of the integration code is the glue around those four points.

The database: one user_passkeys table

Passkeys are credentials that belong to a user; a user can have several. The schema is the smallest thing that captures what WebAuthn needs:

defmodule SlashFeed.Accounts.UserPasskey do
  use Ecto.Schema
  import Ecto.Changeset

  @primary_key {:id, Uniq.UUID, version: 7, autogenerate: true, type: :uuid}
  @foreign_key_type :binary_id

  schema "user_passkeys" do
    field :credential_id, :binary
    field :public_key, :binary
    field :sign_count, :integer, default: 0
    field :aaguid, :binary
    field :name, :string
    field :last_used_at, :utc_datetime

    belongs_to :user, SlashFeed.Accounts.User

    timestamps(type: :utc_datetime)
  end

  def registration_changeset(passkey, attrs) do
    passkey
    |> cast(attrs, [:credential_id, :public_key, :sign_count, :aaguid, :name, :user_id])
    |> validate_required([:credential_id, :public_key, :user_id])
    |> validate_length(:name, max: 64)
    |> unique_constraint(:credential_id,
      message: "this authenticator is already registered to an account"
    )
  end

  def used_changeset(passkey, attrs) do
    passkey
    |> cast(attrs, [:sign_count, :last_used_at])
    |> validate_required([:sign_count, :last_used_at])
  end
end

Five fields are doing the real work:

  • credential_id — raw bytes returned by the authenticator. The unique index here prevents the same hardware key from being silently registered twice.
  • public_key — the COSE key map, serialised with :erlang.term_to_binary/1. On verification I deserialise with [:safe] to avoid the “atoms are forever” footgun.
  • sign_count — a monotonic counter the authenticator increments on every use. If we ever see a credential present a counter lower than what we stored, it’s a sign that someone cloned the authenticator. We reject the login and recommend rotating it.
  • aaguid — 16 bytes that identify the authenticator model. Optional, but lets the UI show “MacBook Touch ID” instead of “Passkey 3.”
  • name — what the user calls it. They have a Mac and a Yubikey; they need to be able to tell them apart in the settings page.

Security-critical fields (credential_id, public_key, user_id) are deliberately set programmatically. They are never in the cast list of any user-facing changeset — only the internal registration_changeset/2. A request that tries to inject user_id into a rename form gets nowhere.

Three Accounts functions wrap the whole thing

The public API on the Accounts context is small. Registration:

def new_registration_challenge(%User{} = _user) do
  Wax.new_registration_challenge()
end

def register_passkey(user, challenge, attestation_obj_b64, client_data_json_b64,
                     credential_id_b64, passkey_name) do
  with {:ok, attestation_obj_raw} <- decode_base64url(attestation_obj_b64),
       {:ok, client_data_json_raw} <- decode_base64url(client_data_json_b64),
       {:ok, credential_id_raw} <- decode_base64url(credential_id_b64),
       {:ok, {auth_data, _attestation_result}} <-
         Wax.register(attestation_obj_raw, client_data_json_raw, challenge),
       {:ok, cose_key, aaguid} <- extract_credential_data(auth_data) do
    # ... insert UserPasskey ...
  end
end

Login:

def new_authentication_challenge(%User{} = user) do
  case list_user_passkeys(user) do
    [] ->
      {:error, :no_passkeys}

    passkeys ->
      allow_credentials =
        Enum.map(passkeys, fn pk ->
          cose_key = :erlang.binary_to_term(pk.public_key, [:safe])
          {pk.credential_id, cose_key}
        end)

      challenge = Wax.new_authentication_challenge(allow_credentials: allow_credentials)
      {:ok, challenge, allow_credentials}
  end
end

def authenticate_with_passkey(challenge, credential_id_b64, auth_data_b64,
                              sig_b64, client_data_b64) do
  with {:ok, credential_id_raw} <- decode_base64url(credential_id_b64),
       # ... other decodes ...
       {:ok, auth_data} <-
         Wax.authenticate(credential_id_raw, auth_data_raw, sig_raw,
                          client_data_raw, challenge, allow_credentials),
       {:ok, passkey} <- record_passkey_use(credential_id_raw, auth_data.sign_count) do
    {:ok, passkey.user}
  end
end

The with pipelines aren’t pretty, but each step in them is doing real work: decoding base64url, calling into wax_, and persisting state. The {:safe} flag on :erlang.binary_to_term/2 is doing security work — without it, a malicious public-key blob in the database could be used to create unbounded atoms when deserialised.

And five management functions for the settings page:

list_user_passkeys(user)
passkey_count(user)
has_passkeys?(user)
rename_passkey(user, passkey_id, new_name)
delete_passkey(user, passkey_id)
get_passkey_by_credential_id(credential_id)

Everything user-scoped takes user as the first argument and verifies ownership before doing anything. There is no “rename any passkey by id” function.

The JavaScript side

This is the part of WebAuthn that always feels stranger than it is. The browser API works in raw binary buffers (ArrayBuffer), so every value crossing the wire has to be base64url-encoded. The hooks are short:

// Registration
const startResp = await fetch("/users/passkeys/registration_challenge")
const { challenge, user, rp } = await startResp.json()

const credential = await navigator.credentials.create({
  publicKey: {
    challenge: base64urlToBuffer(challenge),
    rp,
    user: { ...user, id: base64urlToBuffer(user.id) },
    pubKeyCredParams: [{ type: "public-key", alg: -7 }, { type: "public-key", alg: -257 }],
    authenticatorSelection: { residentKey: "preferred", userVerification: "preferred" },
  }
})

await fetch("/users/passkeys/register", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    credential_id: bufferToBase64url(credential.rawId),
    attestation_object: bufferToBase64url(credential.response.attestationObject),
    client_data_json: bufferToBase64url(credential.response.clientDataJSON),
    name: nameInput.value,
  }),
})

Phoenix LiveView 1.1 lets you write these inline with <script :type={Phoenix.LiveView.ColocatedHook}>...</script> next to the markup they belong to. The hook for login is symmetric, just calling navigator.credentials.get instead of .create.

The gotcha that ate an afternoon: origin binding

WebAuthn credentials are bound to an origin (scheme + host + port) and a relying-party ID (the bare hostname). If the origin configured on the server doesn’t exactly match the URL the browser is on, registration appears to succeed but the resulting credential is unusable for login — wax_ rejects the assertion with invalid_origin or invalid_rp_id, which surfaces as a confusing “credential not found” in the UI.

I hit this twice. Once because I’d typed localhost:3500 in one config file and 127.0.0.1:3500 in another (they are not the same origin to a browser). And once because Tailscale exposed the app on a different hostname than the one I’d configured.

The fix was to centralise the resolution:

defmodule SlashFeed.WebAuthn.Origin do
  @default_port "3500"

  @spec resolve(map()) :: String.t()
  def resolve(env \\ System.get_env()) do
    cond do
      override = env["WEBAUTHN_ORIGIN"] -> override
      app_url = env["APP_URL"]          -> app_url
      host = env["PHX_HOST"]            -> "https://#{host}"
      true -> "http://localhost:#{Map.get(env, "PORT", @default_port)}"
    end
  end

  @spec rp_id(String.t()) :: String.t() | :auto
  def rp_id(origin) when is_binary(origin) do
    case URI.parse(origin) do
      %URI{host: host} when is_binary(host) and host != "" -> host
      _ -> :auto
    end
  end
end

And then config/runtime.exs reads this once and configures wax_:

if config_env() != :test do
  webauthn_origin = SlashFeed.WebAuthn.Origin.resolve(System.get_env())
  webauthn_rp_id  = SlashFeed.WebAuthn.Origin.rp_id(webauthn_origin)

  config :wax_,
    origin: webauthn_origin,
    rp_id: webauthn_rp_id,
    user_verification: "preferred",
    attestation: "none"
end

I added a Mix task — mix slashfeed.webauthn.config — that prints the resolved origin and rp_id alongside the configured values, so a misconfiguration is one command away from being obvious. Worth its weight in saved debugging time.

Testing the parts that don’t need a browser

WebAuthn registration and login fundamentally require a real authenticator producing real signatures. There’s no good way to fake an attestation that wax_ will accept in a unit test (you can in principle generate one — that’s what Wax’s own test suite does — but it’s a lot of setup for limited gain in an app test).

The strategy I settled on:

  1. Unit-test everything around the wax_ call — challenge creation, base64 decoding, ownership checks, ordering, deletion semantics.
  2. Use a fake-credential helper that inserts a UserPasskey row directly with a random credential_id and an arbitrary public-key blob.
  3. Negative-test the register_passkey/6 and authenticate_with_passkey/5 paths — give them garbage input and assert they return {:error, _} cleanly.
  4. Test the HTTP plumbing — the challenge endpoint and the authenticate endpoint — so the route, the JSON shape, and the session handling are all covered.

Here’s the unit test for the management functions and the challenge generator:

defmodule SlashFeed.AccountsPasskeyTest do
  use SlashFeed.DataCase, async: true

  import SlashFeed.AccountsFixtures
  alias SlashFeed.Accounts
  alias SlashFeed.Accounts.UserPasskey
  alias SlashFeed.Repo

  describe "new_registration_challenge/1" do
    test "produces a Wax.Challenge struct" do
      user = user_fixture()
      challenge = Accounts.new_registration_challenge(user)
      assert %Wax.Challenge{} = challenge
      assert is_binary(challenge.bytes)
      assert byte_size(challenge.bytes) >= 16
    end
  end

  describe "list_user_passkeys/1, passkey_count/1, has_passkeys?/1" do
    test "empty by default" do
      user = user_fixture()
      assert Accounts.list_user_passkeys(user) == []
      assert Accounts.passkey_count(user) == 0
      refute Accounts.has_passkeys?(user)
    end

    test "returns inserted passkeys" do
      user = user_fixture()
      insert_passkey(user, name: "Laptop")
      insert_passkey(user, name: "Phone")

      assert [a, b] = Accounts.list_user_passkeys(user)
      assert a.name == "Laptop"
      assert b.name == "Phone"
      assert Accounts.passkey_count(user) == 2
    end
  end

  describe "rename_passkey/3" do
    test "renames a user-owned passkey" do
      user = user_fixture()
      passkey = insert_passkey(user, name: "Old name")
      assert {:ok, updated} = Accounts.rename_passkey(user, passkey.id, "New name")
      assert updated.name == "New name"
    end

    test "returns :not_found for foreign passkeys" do
      user_a = user_fixture()
      user_b = user_fixture()
      passkey = insert_passkey(user_a)
      assert {:error, :not_found} = Accounts.rename_passkey(user_b, passkey.id, "Hijack")
    end
  end

  describe "register_passkey/6" do
    test "fails on invalid base64 input" do
      user = user_fixture()
      challenge = Accounts.new_registration_challenge(user)

      assert {:error, _} =
               Accounts.register_passkey(user, challenge, "not!base64!", "x", "x", "Test")
    end
  end

  describe "authenticate_with_passkey/5" do
    test "fails when the credential is unknown" do
      challenge = Wax.new_authentication_challenge(allow_credentials: [])

      assert {:error, _} =
               Accounts.authenticate_with_passkey(
                 challenge,
                 Base.url_encode64(<<0::256>>, padding: false),
                 Base.url_encode64(<<0::256>>, padding: false),
                 Base.url_encode64(<<0::512>>, padding: false),
                 Base.url_encode64(~s({"x":1}), padding: false)
               )
    end
  end

  defp insert_passkey(user, attrs \\ []) do
    name = Keyword.get(attrs, :name, "Test passkey")
    cred_id = :crypto.strong_rand_bytes(32)

    %UserPasskey{}
    |> UserPasskey.registration_changeset(%{
      user_id: user.id,
      credential_id: cred_id,
      public_key: :erlang.term_to_binary(%{1 => 2}),
      sign_count: 0,
      name: name
    })
    |> Repo.insert!()
  end
end

The insert_passkey/2 helper is the key trick. I’m not pretending to test the WebAuthn protocol — I’m testing that given a passkey exists, all the management code around it does the right thing. The protocol itself is wax_‘s job, and wax_ has its own test suite.

The register_passkey/6 and authenticate_with_passkey/5 tests are deliberately limited to the negative paths. Asserting that bad input is rejected is much easier than asserting that good input is accepted, and from a security standpoint it’s actually the more important property: every code path that could be tricked into accepting a malformed credential is a footgun.

Testing the HTTP layer

The challenge endpoint has a subtle property I almost missed: it must not reveal whether an email belongs to a registered user. WebAuthn standards call this “user enumeration resistance,” and the way you achieve it is by always returning a valid-looking challenge — with an empty allowCredentials array if the user doesn’t exist or has no passkeys. That way the network traffic looks identical in both cases.

The controller test pins this behaviour:

defmodule SlashFeedWeb.PasskeyControllerTest do
  use SlashFeedWeb.ConnCase, async: true

  import SlashFeed.AccountsFixtures

  describe "GET /users/passkeys/challenge" do
    test "returns a challenge JSON for unknown email (no enumeration)", %{conn: conn} do
      conn = get(conn, ~p"/users/passkeys/[email protected]")
      assert %{"challenge" => challenge, "allowCredentials" => []} = json_response(conn, 200)
      assert is_binary(challenge)
      assert byte_size(challenge) > 0
    end

    test "returns a challenge for known user with no passkeys", %{conn: conn} do
      user = user_fixture()
      conn = get(conn, ~p"/users/passkeys/challenge?email=#{user.email}")
      assert %{"challenge" => _, "allowCredentials" => []} = json_response(conn, 200)
    end

    # Regression: the browser hook calls fetch(url, { headers: { accept: 'application/json' } }).
    # If the route is mounted under the regular `:browser` pipeline (`accepts: ["html"]`)
    # Phoenix raises Phoenix.NotAcceptableError and serves the dev error overlay
    # — the response then starts with `# Phoenix.NotAcceptableError…` and the JS
    # hook fails with `Unexpected token '#'`.
    test "accepts requests with Accept: application/json", %{conn: conn} do
      conn =
        conn
        |> put_req_header("accept", "application/json")
        |> get(~p"/users/passkeys/challenge")

      assert %{"challenge" => _} = json_response(conn, 200)
    end
  end

  describe "POST /users/passkeys/authenticate" do
    test "redirects to login when no challenge is in session", %{conn: conn} do
      conn =
        post(conn, ~p"/users/passkeys/authenticate", %{
          "credential_id" => Base.url_encode64("x", padding: false),
          "authenticator_data" => Base.url_encode64("x", padding: false),
          "signature" => Base.url_encode64("x", padding: false),
          "client_data_json" => Base.url_encode64(~s({}), padding: false)
        })

      assert redirected_to(conn) == ~p"/users/log-in"
    end
  end
end

The third test in there — the accept: application/json regression — is the kind of test that only exists because the bug actually happened. The route was initially in the regular :browser pipeline, which only accepts "html", and the JS hook crashed in production with a confusing JSON-parse error. The fix was a dedicated :passkey_browser pipeline that accepts both "json" and "html". The test pins it so I never undo that fix.

What I’d do differently

Build the origin/rp_id resolver first. I started writing the registration flow and then debugged origin mismatches afterwards. If I’d written SlashFeed.WebAuthn.Origin and the mix slashfeed.webauthn.config task before the first Wax.register call, I’d have saved hours.

Don’t try to clean up the with pipelines. I rewrote the register_passkey/6 function three times trying to make the decoding step prettier. The current shape — one with, each step doing one specific thing — is genuinely the right structure. Refactoring it makes it worse.

Always pass [:safe] to binary_to_term/2. I had it without :safe for one commit. Credo didn’t catch it, the tests didn’t catch it. A code review against the Erlang docs page for the function caught it. Now I have a Credo rule that flags any :erlang.binary_to_term call without the option.


The end result is that a user can register a passkey on their settings page in under five seconds and log in with Touch ID instead of a password from then on. The whole integration is about 900 lines of application code on top of wax_‘s reading of the spec. The protocol does what it says on the box — phishing-resistant, hardware-backed, no shared secrets in your database — and the library does the cryptographic heavy lifting so the part you write is just plumbing.

If you’re running a Phoenix app and still have a password as the primary factor, this is a weekend project that meaningfully changes your security posture. Worth doing.

SlashFeed is a personal RSS reader built in Elixir/Phoenix. I’m documenting the development process here as I go.