Passkeys as Second Factor (2FA)

View Source

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 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?

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 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.
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:

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?
primarytruetruetrue
2fafalsefalsetrue

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.

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

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_prefixrequired, 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:

{
  "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

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:

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:

SituationAction
No current userPass 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 claimon_unverified (default :redirect_to_verify)
Token has webauthn_verified_at and freshness check passesPass through

Plug options

OptionDescriptionDefault
resourceThe user resource moduleRequired
strategyWebAuthn strategy nameFirst 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_pathPath to redirect to when unconfigured"/webauthn-setup"
verify_pathPath to redirect to when unverified"/webauthn-verify"
max_ageMaximum age of webauthn_verified_at (seconds) before requiring re-verificationnil (no expiry)
current_user_assignAssign holding the user:current_user

LiveSession.RequireWebAuthn

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

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:

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:

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 requestsRequireWebAuthn 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

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):

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.

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.)

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; 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 for the parallel TOTP plumbing.

Recovery

When a user loses access to their passkey, the recovery code add-on 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

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.

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 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