Skip to main content

documentation/tutorials/webauthn-2fa.md

<!--
SPDX-FileCopyrightText: 2026 Alembic Pty Ltd

SPDX-License-Identifier: MIT
-->

# Passkeys as Second Factor (2FA)

This guide explains how to use a WebAuthn passkey as a *second* authentication
factor — i.e. as a step the user performs *after* signing in with their primary
credential (typically a password). For setting WebAuthn up as the *primary*
sign-in method, see the [WebAuthn / Passkey Authentication](webauthn.md) guide
instead.

## Overview

Two-factor authentication (2FA) requires two different forms of verification:

1. **Something you know** — a password, magic link, etc.
2. **Something you have** — a hardware security key, Touch ID / Face ID, or a
   passkey on the user's phone.

With passkey-based 2FA enabled, users sign in with their primary credential
first, then complete a WebAuthn ceremony. The result of the ceremony is
recorded against the user's authentication token as a `webauthn_verified_at`
claim, which protected routes can require.

> ### Why not just use passkeys as primary? {: .info}
>
> Passkeys-as-primary is great when you want a passwordless experience. 2FA
> mode is the right choice when:
>
> - You want to keep an existing password flow but add a hardware-bound second
>   step (defence in depth).
> - You're rolling out passkeys gradually — users without a registered
>   passkey can still sign in normally; only protected routes require the
>   second factor.
> - Compliance or threat models require multiple independent factors.
>
> The two modes can also coexist: a user can use a passkey to sign in
> *primarily*, and a different strategy (e.g. TOTP) as the second factor. See
> the [TOTP 2FA guide](totp-2fa.md) for that variant.

## Prerequisites

Ensure you have:

1. A primary authentication strategy configured (password, magic link, etc.).
2. The WebAuthn strategy configured **in 2FA mode** (see below).
3. Tokens enabled — 2FA verification depends on the token-claim plumbing.

```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
      password :password do
        identity_field :email
        hashed_password_field :hashed_password
        sign_in_tokens_enabled? true
      end

      webauthn do
        credential_resource MyApp.Accounts.WebAuthnCredential
        rp_id MyApp.Secrets
        rp_name MyApp.Secrets
        origin MyApp.Secrets
        identity_field :email

        # 2FA mode: the strategy doesn't register or sign users in directly,
        # it only verifies an assertion against an already-authenticated user.
        registration_enabled? false
        sign_in_enabled?      false
        verify_enabled?       true
      end
    end
  end
end
```

## Installation

The fastest path is the installer:

```bash
mix ash_authentication_phoenix.add_strategy webauthn --mode 2fa
```

`--mode 2fa` is what changes the generated configuration:

| Mode (default `primary`) | `registration_enabled?` | `sign_in_enabled?` | `verify_enabled?` |
| --- | --- | --- | --- |
| `primary`                | `true`                  | `true`             | `true`            |
| `2fa`                    | `false`                 | `false`            | `true`            |

In 2FA mode the installer also:

- Generates the credential resource (same as primary mode).
- Registers `webauthn_2fa_route` and `webauthn_setup_route` macros in your
  router (see below).
- Adds `success/4` clauses to your `AuthController` that intercept primary
  sign-ins and route the user through the verify page.

## Built-in WebAuthn 2FA routes

`ash_authentication_phoenix` provides two router macros that mount the LiveView
pages used by the 2FA flow. They're analogous to the TOTP equivalents:

### `webauthn_2fa_route`

The verification page. Mounted by default at `/webauthn-verify`. Two flows:

- **Token flow** (`/webauthn-verify/:token`) — used after a primary sign-in. The
  `:token` is a short-lived JWT containing the user's subject; the page
  performs the WebAuthn ceremony, exchanges the asserted token for a session
  on success.
- **Step-up flow** (`/webauthn-verify`) — used by an already-signed-in user
  re-asserting their second factor for a sensitive action.

### `webauthn_setup_route`

The setup page. Mounted by default at `/webauthn-setup`. An authenticated user
registers a new passkey to their account here. Wraps the existing
`AshAuthentication.Phoenix.Components.WebAuthn.ManageCredentials` component, so
the same screen also lets users see, label, and revoke existing credentials.

```elixir
defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  use AshAuthentication.Phoenix.Router

  pipeline :browser do
    plug :accepts, ["html"]
    plug :fetch_session
    plug :fetch_live_flash
    plug :put_root_layout, html: {MyAppWeb.Layouts, :root}
    plug :protect_from_forgery
    plug :put_secure_browser_headers
    plug :load_from_session
    plug :set_actor, :user
  end

  scope "/", MyAppWeb do
    pipe_through :browser

    auth_routes AuthController, MyApp.Accounts.User, path: "/auth"

    sign_in_route auth_routes_prefix: "/auth"

    webauthn_2fa_route   MyApp.Accounts.User, :webauthn, auth_routes_prefix: "/auth"
    webauthn_setup_route MyApp.Accounts.User, :webauthn, auth_routes_prefix: "/auth"
  end
end
```

> ### Browser pipeline {: .info}
>
> `plug :load_from_session` and `plug :set_actor, :user` are required. The
> 2FA verify ceremony reads the user from `Ash.PlugHelpers.get_actor/1`, which
> relies on those two plugs having run.

Options for both macros (matching the TOTP versions):

- `path` — override the default mount path.
- `live_view` — supply a custom LiveView module.
- `auth_routes_prefix` — **required**, makes the form post URLs work.
- `overrides` — list of override modules for UI customisation.

## How verification is recorded — the `webauthn_verified_at` claim

When the user completes the WebAuthn ceremony, the strategy's verify action
returns a fresh JWT for the user with one extra claim:

```json
{
  "sub": "user?id=...",
  "purpose": "sign_in",
  "webauthn_verified_at": "2026-05-07T12:34:56Z"
}
```

That claim is the source of truth for "this session has been verified by a
passkey". `store_in_session` also stamps the matching
`:webauthn_verified_at` value onto the user's metadata, which survives
`load_from_session` round-trips for browser flows. The JWT claim covers
headless / `Authorization: Bearer …` flows. Both forms get preserved
through the `sign_in_with_token` exchange.

> ### What's deliberately *not* in the claim {: .warning}
>
> Only the timestamp is recorded. The credential ID, authenticator make /
> model, and any other device-fingerprinting information stay on the server.
> A leaked token cannot be used to identify the user's specific authenticator.

## Requiring WebAuthn for protected routes

### `Plug.RequireWebAuthn`

Use this for controller-based routes:

```elixir
defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  use AshAuthentication.Phoenix.Router

  pipeline :require_webauthn do
    plug AshAuthentication.Phoenix.Plug.RequireWebAuthn,
      resource: MyApp.Accounts.User,
      on_unverified: :redirect_to_verify,
      on_unconfigured: :redirect_to_setup
  end

  scope "/", MyAppWeb do
    pipe_through [:browser, :require_webauthn]

    get "/admin", AdminController, :index
  end
end
```

`RequireWebAuthn` reads the user's authentication token claims via
`AshAuthentication.Plug.Helpers.retrieve_from_session/2` and decides what to
do:

| Situation | Action |
| --- | --- |
| No current user | Pass through (let your auth pipeline handle it) |
| User has no registered passkeys (`webauthn_configured?` is `false`) | `on_unconfigured` (default `:redirect_to_setup`) |
| User has passkeys but token has no `webauthn_verified_at` claim | `on_unverified` (default `:redirect_to_verify`) |
| Token has `webauthn_verified_at` and freshness check passes | Pass through |

#### Plug options

| Option | Description | Default |
| --- | --- | --- |
| `resource` | The user resource module | Required |
| `strategy` | WebAuthn strategy name | First WebAuthn strategy on the resource |
| `on_unconfigured` | `:halt` \| `:redirect_to_setup` \| `{:redirect, path}` | `:redirect_to_setup` |
| `on_unverified` | `:halt` \| `:redirect_to_verify` \| `{:redirect, path}` | `:redirect_to_verify` |
| `setup_path` | Path to redirect to when unconfigured | `"/webauthn-setup"` |
| `verify_path` | Path to redirect to when unverified | `"/webauthn-verify"` |
| `max_age` | Maximum age of `webauthn_verified_at` (seconds) before requiring re-verification | `nil` (no expiry) |
| `current_user_assign` | Assign holding the user | `:current_user` |

### `LiveSession.RequireWebAuthn`

The LiveView equivalent — wire it into `ash_authentication_live_session`'s
`on_mount`:

```elixir
scope "/", MyAppWeb do
  pipe_through :browser

  ash_authentication_live_session :admin,
    on_mount: [
      {AshAuthentication.Phoenix.LiveSession.RequireWebAuthn, :require_webauthn}
    ] do
    live "/admin/dashboard", AdminLive
  end
end
```

Pass options as a tuple to tune behaviour:

```elixir
on_mount: [
  {AshAuthentication.Phoenix.LiveSession.RequireWebAuthn,
    {:require_webauthn, max_age: 300, verify_path: "/step-up"}}
]
```

## Auth controller integration

When using the `--mode 2fa` installer, this clause gets prepended to your
`AuthController`:

```elixir
defmodule MyAppWeb.AuthController do
  use MyAppWeb, :controller
  use AshAuthentication.Phoenix.Controller

  def success(conn, {_, phase} = _activity, user, token)
      when phase in [:sign_in, :sign_in_with_token] do
    return_to = get_session(conn, :return_to) || ~p"/"

    cond do
      AshAuthentication.Phoenix.WebAuthnHelpers.webauthn_verified?(user) ->
        conn
        |> store_in_session(user)
        |> set_live_socket_id(token)
        |> assign(:current_user, user)
        |> redirect(to: return_to)

      AshAuthentication.Phoenix.WebAuthnHelpers.webauthn_configured?(user) ->
        conn
        |> store_in_session(user)
        |> set_live_socket_id(token)
        |> assign(:current_user, user)
        |> put_session(:return_to, return_to)
        |> redirect(to: ~p"/webauthn-verify/#{token}")

      true ->
        conn
        |> store_in_session(user)
        |> set_live_socket_id(token)
        |> assign(:current_user, user)
        |> put_session(:return_to, return_to)
        |> redirect(to: ~p"/webauthn-setup")
    end
  end

  # Default catch-all for other auth activities (registration, sign-out, …).
  def success(conn, activity, user, token), do: # ...
end
```

The single clause matches both the *initial* primary sign-in and the
*follow-up* `sign_in_with_token` that the WebAuthn LiveView triggers after a
successful ceremony, distinguished by the user's
`:webauthn_verified_at` metadata:

1. **Primary sign-in succeeds, no `:webauthn_verified_at` yet** — fall
   through to the second `cond` arm and route to `/webauthn-verify` (or
   `/webauthn-setup` if the user has no passkeys yet).
2. **WebAuthn ceremony completes** — the strategy returns a fresh user
   with `:webauthn_verified_at` set; the LiveView trigger-submits the token
   to `sign_in_with_token`; the first arm matches; the user is redirected
   to their original destination.
3. **Subsequent requests** — `RequireWebAuthn` reads the metadata (browser
   flows) or the JWT claim (bearer flows) and lets the user through until
   the session expires or the optional `:max_age` window elapses.

## `WebAuthnHelpers`

```elixir
alias AshAuthentication.Phoenix.WebAuthnHelpers

# Does the user have at least one registered passkey?
WebAuthnHelpers.webauthn_configured?(user)
#=> true

# Does the *current request* have a valid webauthn_verified_at claim?
WebAuthnHelpers.webauthn_verified?(conn_or_socket)
#=> true

# Same as above, with a freshness window in seconds.
WebAuthnHelpers.webauthn_verified?(conn_or_socket, max_age: 300)
#=> false   # last verification was 6 minutes ago

# Get the WebAuthn strategy on a resource.
{:ok, strategy} = WebAuthnHelpers.get_webauthn_strategy(MyApp.Accounts.User)
```

## Setup page

The installer mounts a setup page at `/webauthn-setup` that wraps the
existing `ManageCredentials` component, so users can register, label, and
revoke passkeys from one screen.

In the post-primary-sign-in flow, the AuthController stashes the original
destination in the session as `:return_to` before redirecting to setup.
`WebAuthnSetupLive` reads it and threads it into `ManageCredentials` as
`continue_path`; once the user has registered at least one credential, a
"Continue" button appears that takes them on to that destination.

If you want to render the setup form yourself (e.g. inside a settings page):

```elixir
defmodule MyAppWeb.SecuritySettingsLive do
  use MyAppWeb, :live_view
  alias AshAuthentication.Phoenix.Components.WebAuthn.ManageCredentials

  def mount(_params, _session, socket) do
    {:ok, strategy} =
      AshAuthentication.Info.strategy(MyApp.Accounts.User, :webauthn)

    {:ok, assign(socket, strategy: strategy)}
  end

  def render(assigns) do
    ~H"""
    <h1>Security</h1>

    <.live_component
      module={ManageCredentials}
      id="webauthn-credentials"
      strategy={@strategy}
      current_user={@current_user}
    />
    """
  end
end
```

Pass `continue_path={"/some/path"}` to the live component to surface the
"Continue" affordance in your own setup screens too.

## Step-up authentication

The same verify page handles "I'm signed in but I need to re-prove this is
me before doing X". Send the user to `/webauthn-verify` (no `:token` —
that's the trigger for step-up mode) and they get a one-shot ceremony. On
success the JWT is re-issued with a fresh `:webauthn_verified_at` and
`RequireWebAuthn`'s `:max_age` check passes again.

```elixir
defmodule MyAppWeb.AdminLive do
  use MyAppWeb, :live_view
  alias AshAuthentication.Phoenix.WebAuthnHelpers

  def handle_event("delete_user", %{"id" => id}, socket) do
    if WebAuthnHelpers.webauthn_verified?(socket, max_age: 300) do
      # Recently verified — proceed.
      {:noreply, do_delete(socket, id)}
    else
      # Stash where to come back to, then redirect.
      {:noreply,
       socket
       |> put_flash(:info, "Please re-verify with your passkey to continue.")
       |> push_navigate(to: "/webauthn-verify")}
    end
  end
end
```

`RequireWebAuthn` writes the original destination into `:return_to` for
you when it does the redirect; for hand-rolled redirects like the example
above you'll need to thread that yourself if you want to land back on the
original page. (We're tracking a nicer step-up affordance —
`AshAuthentication.Phoenix.WebAuthnHelpers.step_up/2` — alongside the
multi-factor chooser in
[#740](https://github.com/team-alembic/ash_authentication_phoenix/issues/740).)

## Combining with TOTP

Mounting both `webauthn_2fa_route` and `totp_2fa_route` on the same resource
is supported, but the installed `AuthController.success/4` clauses currently
favour whichever strategy was added last. A user-facing chooser ("verify
with passkey or TOTP code?") is being tracked in
[#740](https://github.com/team-alembic/ash_authentication_phoenix/issues/740);
until it lands, pick one as the canonical path or hand-write a
`success/4` clause that branches on user preference.

See the [TOTP as 2FA guide](totp-2fa.md) for the parallel TOTP plumbing.

## Recovery

When a user loses access to their passkey, the
[recovery code add-on](recovery-codes.md) gives them a one-time fallback.
Generate recovery codes during onboarding and surface them in the security
settings page.

> ### Heads up — recovery currently bypasses, not replaces, the WebAuthn check {: .warning}
>
> `RequireWebAuthn` only honours `:webauthn_verified_at`. A successful
> recovery code completes primary sign-in but doesn't satisfy the WebAuthn
> requirement on its own — so recovery should usually be paired with a
> setup-or-disable flow that lets the user register a fresh passkey before
> they hit a `RequireWebAuthn`-protected route. Cross-strategy verification
> ("recovery counts as second factor") is being tracked alongside the
> [multi-factor chooser](https://github.com/team-alembic/ash_authentication_phoenix/issues/740).

## Headless / API clients

Because the verification status lives in the JWT (not just session cookies),
non-browser clients can use the same flow:

1. Primary auth → token A (no `webauthn_verified_at`).
2. Client posts assertion to `POST /auth/<subject>/webauthn/verify` with
   `Authorization: Bearer <token A>`.
3. Server returns token B containing `webauthn_verified_at`.
4. Client uses token B for protected calls.

The browser flow is just a UI on top of these endpoints.

## Security notes

### Verified versus configured

It's worth being explicit about the model:

- `webauthn_configured?` — does the user have at least one passkey registered?
- `webauthn_verified?` — has the *current request's* token been issued by a
  successful WebAuthn ceremony?

Protected routes should require **verified**, not just configured. The
default `RequireWebAuthn` plug enforces this. (Compare with the TOTP
helpers, where the equivalent `RequireTotp` only checks *configured* — see
the [TOTP 2FA guide](totp-2fa.md) for the rationale.)

### Freshness

For high-impact actions (deleting an account, transferring funds, changing
passwords), set a short `max_age` on `RequireWebAuthn` so the user has to
touch their passkey again, even within an otherwise-valid session.

### Replay

The verify endpoint validates a Wax challenge that was issued by the same
server within the last 60 seconds (configurable via
`AshAuthentication.Strategy.WebAuthn`'s `:timeout` option). Replaying an old
assertion against a stale challenge is rejected.

### Credential-to-user binding

The verify action filters credential lookups by `user_id == actor.id`, so a
ceremony presented with a credential belonging to a *different* user is
rejected even if the signature would otherwise verify. This is enforced
server-side and isn't user-configurable.

## Next steps

- [WebAuthn / Passkey Authentication](webauthn.md) — primary-mode setup.
- [TOTP as 2FA](totp-2fa.md) — the TOTP analogue of this guide.
- [Recovery Codes](recovery-codes.md) — fallback factor for when the user
  loses access to their passkey.
- [LiveView Integration](liveview.md) — broader LiveView auth patterns.
- [UI Overrides](ui-overrides.md) — customising the verify and setup
  components.