Passkeys as Second Factor (2FA)
View SourceThis 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:
- Something you know — a password, magic link, etc.
- 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:
- A primary authentication strategy configured (password, magic link, etc.).
- The WebAuthn strategy configured in 2FA mode (see below).
- 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
endInstallation
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? |
|---|---|---|---|
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_routeandwebauthn_setup_routemacros in your router (see below). - Adds
success/4clauses to yourAuthControllerthat 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:tokenis 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
endBrowser 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_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:
{
"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
endRequireWebAuthn 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:
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
endPass 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: # ...
endThe 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:
- Primary sign-in succeeds, no
:webauthn_verified_atyet — fall through to the secondcondarm and route to/webauthn-verify(or/webauthn-setupif the user has no passkeys yet). - WebAuthn ceremony completes — the strategy returns a fresh user
with
:webauthn_verified_atset; the LiveView trigger-submits the token tosign_in_with_token; the first arm matches; the user is redirected to their original destination. - Subsequent requests —
RequireWebAuthnreads the metadata (browser flows) or the JWT claim (bearer flows) and lets the user through until the session expires or the optional:max_agewindow 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
endPass 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
endRequireWebAuthn 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:
- Primary auth → token A (no
webauthn_verified_at). - Client posts assertion to
POST /auth/<subject>/webauthn/verifywithAuthorization: Bearer <token A>. - Server returns token B containing
webauthn_verified_at. - 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
- WebAuthn / Passkey Authentication — primary-mode setup.
- TOTP as 2FA — the TOTP analogue of this guide.
- Recovery Codes — fallback factor for when the user loses access to their passkey.
- LiveView Integration — broader LiveView auth patterns.
- UI Overrides — customising the verify and setup components.