<!--
SPDX-FileCopyrightText: 2026 Alembic Pty Ltd
SPDX-License-Identifier: MIT
-->
# WebAuthn / Passkey Authentication
WebAuthn lets users sign in with hardware security keys (YubiKey), platform authenticators (Touch ID, Windows Hello, Face ID), or passkeys. This guide covers end-to-end setup for using a passkey as the **primary** authentication credential — backend strategy, Phoenix components, and the JavaScript hooks required for the WebAuthn ceremony.
> ### Looking for second-factor (2FA) setup? {: .info}
>
> If you want a passkey to act as a *second* factor on top of an existing
> primary credential (typically a password), see the
> [Passkeys as 2FA](webauthn-2fa.md) guide. The same WebAuthn strategy
> supports both modes via the `--mode` installer flag.
## Overview
WebAuthn authentication has more moving parts than other strategies because the browser participates in the cryptographic ceremony. At a high level:
1. The server issues a **challenge** (registration or authentication).
2. The browser invokes `navigator.credentials.create` / `.get` with the challenge.
3. The authenticator (hardware key, platform biometric) signs the challenge.
4. The server verifies the signed response and creates/authenticates the user.
`ash_authentication_phoenix` provides the Phoenix components and JavaScript hooks that drive this flow against an `AshAuthentication.Strategy.WebAuthn` backend.
## Installation
The installer wires the strategy, credential resource, JS hooks, and
configuration in a single step. Run from your Phoenix project root:
```bash
mix ash_authentication_phoenix.add_strategy webauthn
```
That defaults to `--mode primary` — passkeys as the user's primary
credential. For passkey-as-second-factor instead, pass `--mode 2fa` and
follow the [Passkeys as 2FA](webauthn-2fa.md) guide.
The installer is idempotent — re-running it will not duplicate config or
overwrite changes you've made by hand.
## Prerequisites
If you're configuring the strategy by hand instead of using the installer,
see the [AshAuthentication WebAuthn guide](https://hexdocs.pm/ash_authentication/webauthn.html)
for the backend setup. At minimum, your user resource needs a WebAuthn
strategy with a credential resource. The installer-generated form
threads `rp_id`, `rp_name`, and `origin` through the user's `Secrets`
module so they can be set per-environment via the application
environment:
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication],
domain: MyApp.Accounts
authentication do
tokens do
enabled? true
token_resource MyApp.Accounts.Token
signing_secret MyApp.Secrets
end
strategies do
webauthn do
credential_resource MyApp.Accounts.WebAuthnCredential
rp_id MyApp.Secrets
rp_name MyApp.Secrets
origin MyApp.Secrets
identity_field :email
end
end
end
end
```
Static literals (`rp_id "example.com"`, etc.) are still accepted; the
Secrets-module form is what the installer uses by default.
The `credential_resource` is a separate Ash resource that stores each
registered credential (public key, sign count, label, etc.).
## Router setup
WebAuthn uses the same `sign_in_route` macro as other strategies — the
installer slots it into your existing scope automatically. The `SignIn`
component auto-discovers the WebAuthn strategy and renders the registration /
authentication forms beside any other strategies you have configured:
```elixir
defmodule MyAppWeb.Router do
use MyAppWeb, :router
use AshAuthentication.Phoenix.Router
pipeline :browser do
# ...
end
scope "/", MyAppWeb do
pipe_through :browser
sign_in_route auth_routes_prefix: "/auth",
on_mount: [{MyAppWeb.LiveUserAuth, :live_no_user}]
end
end
```
## JavaScript hooks
WebAuthn requires LiveView hooks to invoke the browser's credential APIs.
The installer wires this into your `assets/js/app.js` automatically. If
you're doing it by hand:
```javascript
import {
WebAuthnRegistrationHook,
WebAuthnAuthenticationHook,
WebAuthnSupportHook
} from "ash_authentication_phoenix/priv/static/webauthn_hooks.js"
const Hooks = {
WebAuthnRegistrationHook,
WebAuthnAuthenticationHook,
WebAuthnSupportHook
}
const liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: Hooks
})
```
Each hook drives a different part of the ceremony:
- **`WebAuthnSupportHook`** — Detects whether the browser supports WebAuthn and conditionally shows/hides the passkey UI.
- **`WebAuthnRegistrationHook`** — Handles the `navigator.credentials.create` call during registration (new passkey).
- **`WebAuthnAuthenticationHook`** — Handles the `navigator.credentials.get` call during sign-in.
The hooks communicate with the server via `pushEventTo` / `handle_event`
— you don't need to write any custom JavaScript.
## Origin and `rp_id` configuration
The installer seeds three application-environment-driven settings on the
strategy via the user's `Secrets` module:
| Setting | What it is | Dev seed (`config/dev.exs`) | Prod seed (`config/runtime.exs`) |
| -------------------- | ----------------------------------------- | --------------------------- | -------------------------------- |
| `:webauthn_rp_id` | Domain only (Relying Party ID) | `"localhost"` | `System.get_env("WEBAUTHN_RP_ID")` |
| `:webauthn_rp_name` | Display name shown in the browser prompt | humanised app name | `System.get_env("WEBAUTHN_RP_NAME")` |
| `:webauthn_origin` | Optional explicit origin override | *(unset)* | `System.get_env("WEBAUTHN_ORIGIN")` |
When `:webauthn_origin` is unset the strategy uses the request origin —
`scheme://host[:port]` derived from `socket.host_uri` (LiveView) or the
`Plug.Conn` (controllers). That keeps dev "just work" against whatever
port Phoenix is on (`4000`, `4001`, …) without anyone editing config.
Set `:webauthn_origin` explicitly in prod (or any env where you don't
want to trust the request origin) to enforce a known value.
**`rp_id` must be a hostname** — no scheme, no port. WebAuthn rejects
`"localhost:4000"` or `"https://example.com"` with a `SecurityError`
because `rp_id` is a [domain string](https://www.w3.org/TR/webauthn-2/#rp-id),
not an origin. The browser validates the origin (scheme + host + port)
separately against the page URL.
WebAuthn over plain HTTP is only allowed when the host is `localhost` or
`127.0.0.1`. Any other hostname requires HTTPS, even in development.
Credentials registered against one `rp_id` are bound to it — changing
`rp_id` later invalidates existing credentials. Pick your production
`rp_id` carefully (a bare apex like `"example.com"` covers subdomains;
`"www.example.com"` is more restrictive).
## Credential management
The library ships with a `ManageCredentials` component that lets authenticated users add, rename, and remove their passkeys:
```heex
<.live_component
module={AshAuthentication.Phoenix.Components.WebAuthn.ManageCredentials}
id="webauthn-credentials"
strategy={@webauthn_strategy}
current_user={@current_user}
/>
```
Deletion of the last credential is prevented to avoid locking users out of their accounts. All credential operations route through `AshAuthentication.Strategy.WebAuthn.Actions`, so policies, hooks, and validations defined on the credential resource are honored.
## Customization
All WebAuthn components support the standard override mechanism. You can customize button text, CSS classes, and icons via your overrides module:
```elixir
defmodule MyAppWeb.AuthOverrides do
use AshAuthentication.Phoenix.Overrides
override AshAuthentication.Phoenix.Components.WebAuthn.AuthenticationForm do
set :button_text, "Sign in with your security key"
end
end
```
See [UI Overrides](ui-overrides.md) for the full list of overridable slots.
## Troubleshooting
- **"SecurityError" / "The relying party ID is not a registrable domain suffix"** — Your `rp_id` includes a port (e.g. `"localhost:4000"`) or a scheme (e.g. `"https://example.com"`). Strip it to the bare hostname.
- **"NotAllowedError" in the browser** — Usually a mismatch between `rp_id` and the page origin, or the user cancelled the prompt.
- **WebAuthn prompt never appears in dev** — You're serving over plain HTTP from a hostname other than `localhost` / `127.0.0.1`. Either use `localhost` or run dev over HTTPS.
- **Credentials missing after deploy** — The `rp_id` likely changed. Credentials are bound to the exact `rp_id` they were registered under.
- **Hooks not firing** — Verify all three hooks are registered in `app.js` and that the LiveView is using them. Check the browser console for hook initialization errors.
- **"Failed to register new key"** — Check that the `credential_resource` exists and that the `:create` action accepts the credential attributes.