Strategy for authenticating using WebAuthn/FIDO2 hardware security keys and passkeys.
This strategy supports:
- Hardware security keys (YubiKey, etc.)
- Platform authenticators (Touch ID, Windows Hello, Face ID)
- Discoverable credentials / passkeys
- Multi-tenancy (dynamic
rp_idper tenant)
Credentials are stored in a separate Ash resource that you define. The strategy auto-generates actions on both the user resource and the credential resource for registration, sign-in, credential management, and challenge generation.
Modes
The strategy can be configured for two roles via the registration_enabled?,
sign_in_enabled?, and verify_enabled? flags:
- Primary (default; all three flags
true) — passkeys are the primary credential. Users register and sign in directly with their authenticator. - Second factor (
registration_enabled? false,sign_in_enabled? false,verify_enabled? true) — passkeys are only used as a second factor on top of another primary credential (e.g. password). The strategy doesn't register or sign in users directly; it only verifies an assertion against the currently authenticated user. On successful verification, awebauthn_verified_atclaim is added to the user's authentication token so protected routes can require it.
See the Passkeys as 2FA guide for the second-factor flow end to end.
Quick Start
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication],
domain: MyApp.Accounts
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
end
authentication do
tokens do
enabled? true
token_resource MyApp.Accounts.Token
signing_secret fn _, _ -> {:ok, Application.get_env(:my_app, :token_secret)} end
end
strategies do
webauthn :webauthn do
credential_resource MyApp.Accounts.Credential
rp_id "example.com"
rp_name "My App"
origin "https://example.com"
identity_field :email
end
end
end
relationships do
has_many :webauthn_credentials, MyApp.Accounts.Credential
end
identities do
identity :unique_email, [:email]
end
endOrigin Configuration
The origin is the full URL the browser sends during WebAuthn ceremonies (scheme + domain + port). The rp_id is the domain name only. These are related but distinct:
| Setting | Example | What it is |
|---|---|---|
rp_id | "example.com" | Domain only (Relying Party ID) |
origin | "https://example.com" | Full URL including scheme + port |
If origin is not set, it defaults to "https://{rp_id}". This works for
production on standard port 443, but breaks in development because the
browser includes the port in the origin and Wax will reject the mismatch.
Production
origin "https://example.com"Development (non-standard port)
origin "https://localhost:4001"Multi-tenant (dynamic per tenant)
origin {MyApp.WebAuthn, :origin_for_tenant, []}Credential Resource
You must define a separate Ash resource to store WebAuthn credentials. It needs:
credential_id(:binary) - the raw credential ID from the authenticatorpublic_key(AshAuthentication.Strategy.WebAuthn.CoseKey) - the COSE public keysign_count(:integer) - replay attack counterlabel(:string) - user-facing name for the credentiallast_used_at(:utc_datetime_usec, optional) - tracks last authentication time- A
belongs_torelationship to your user resource - A policy bypass for
AshAuthentication.Checks.AshAuthenticationInteraction
Full Example
defmodule MyApp.Accounts.Credential do
use Ash.Resource,
domain: MyApp.Accounts,
data_layer: AshPostgres.DataLayer,
authorizers: [Ash.Policy.Authorizer]
postgres do
table "webauthn_credentials"
repo(MyApp.Repo)
end
policies do
bypass AshAuthentication.Checks.AshAuthenticationInteraction do
authorize_if always()
end
policy always() do
authorize_if always()
end
end
attributes do
uuid_primary_key :id
attribute :credential_id, :binary, allow_nil?: false, public?: true
attribute :public_key, AshAuthentication.Strategy.WebAuthn.CoseKey,
allow_nil?: false, public?: true
attribute :sign_count, :integer, default: 0, allow_nil?: false, public?: true
attribute :label, :string, default: "Security Key", public?: true
attribute :last_used_at, :utc_datetime_usec, public?: true
create_timestamp :inserted_at
update_timestamp :updated_at
end
relationships do
belongs_to :user, MyApp.Accounts.User, allow_nil?: false, public?: true
end
identities do
identity :unique_credential_id, [:credential_id]
end
actions do
defaults [:read, :destroy]
create :create do
primary? true
accept [:credential_id, :public_key, :sign_count, :label, :user_id]
end
update :update do
primary? true
accept [:sign_count, :label, :last_used_at]
end
end
endToken Configuration
Tokens must be enabled for WebAuthn to work. The signing_secret callback
must return an {:ok, value} tuple, not a raw string:
authentication do
tokens do
enabled? true
token_resource MyApp.Accounts.Token
signing_secret fn _, _ -> {:ok, Application.get_env(:my_app, :token_secret)} end
end
endAdding Credentials to Existing Users
The built-in register action creates a new user with a credential. To add
a passkey to an already-authenticated user, you need a custom controller that:
- Generates a registration challenge (via
Wax.new_registration_challenge/1) - Sends it to the browser
- Receives the attestation response
- Calls
Wax.register/3to verify it - Stores the credential directly on the credential resource
This is intentional -- the strategy's register flow is for new user sign-up, not for adding keys to existing accounts.
Accessing the User After Authentication
After successful WebAuthn sign-in, the JWT is available in user metadata:
token = user.__metadata__[:token]To load a user from a token (e.g., in a LiveView mount):
{:ok, user} = AshAuthentication.subject_to_user(
"user?id=#{user_id}",
MyApp.Accounts.User
)Multi-Tenancy
For multi-tenant applications, rp_id, rp_name, and origin all accept
MFA tuples that receive the tenant as the first argument:
webauthn :webauthn do
credential_resource MyApp.Accounts.Credential
rp_id {MyApp.WebAuthn, :rp_id_for_tenant, []}
rp_name {MyApp.WebAuthn, :rp_name_for_tenant, []}
origin {MyApp.WebAuthn, :origin_for_tenant, []}
identity_field :email
endThen implement the callbacks:
defmodule MyApp.WebAuthn do
def rp_id_for_tenant(tenant), do: "#{tenant}.example.com"
def rp_name_for_tenant(tenant), do: "MyApp - #{tenant}"
def origin_for_tenant(tenant), do: "https://#{tenant}.example.com"
endGotchas
- Origin must include the port for non-standard ports (e.g.,
"https://localhost:4001"). The default derivation fromrp_idproduces"https://{rp_id}"which omits the port. - Signing secret must return
{:ok, value}, not a raw string. A common mistake isfn _, _ -> "my_secret" end-- it must befn _, _ -> {:ok, "my_secret"} end. - Challenge data is stored in the session as plain maps, not
Wax.Challengestructs, because cookie session stores cannot serialize arbitrary Elixir structs. The plug reconstructs the struct before passing it to Wax. add_credential(adding a key to an existing user) is not built-in. Theregisteraction creates a new user. See "Adding Credentials to Existing Users" above.origin_verify_funis hardcoded to{Wax, :origins_match?, []}when reconstructing challenges from the session. If you need custom origin verification, you'll need to override the plug.- Token generation happens in
Actions.sign_inviaJwt.token_for_user/3, not in an Ash preparation like the Password strategy. This is because Wax verification happens outside the Ash action pipeline.
authentication.strategies.webauthn
webauthn name \\ :webauthnStrategy for authenticating using WebAuthn/FIDO2 hardware security keys and passkeys.
Examples
webauthn :webauthn do
credential_resource MyApp.Accounts.Credential
rp_id "example.com"
rp_name "My App"
identity_field :email
end
Options
| Name | Type | Default | Docs |
|---|---|---|---|
credential_resource | atom | module | The Ash resource used to store WebAuthn credentials. Must have credential_id (binary), public_key (binary), and sign_count (integer) attributes, plus a belongs_to relationship to the user resource. | |
rp_id | String.t | (any -> any) | mfa | (any, any -> any) | module | Relying Party ID - your domain name (e.g. "example.com"). For multi-tenant setups, pass an MFA tuple or 1-arity function that receives the tenant and returns the domain string: rp_id {MyApp.WebAuthn, :rp_id_for_tenant, []} For application-environment-driven configuration, point at a module implementing AshAuthentication.Secret: rp_id MyApp.Secrets | |
rp_name | String.t | (any -> any) | mfa | (any, any -> any) | module | Relying Party display name shown to the user during registration. For multi-tenant setups, pass an MFA tuple or 1-arity function: rp_name {MyApp.WebAuthn, :rp_name_for_tenant, []} For application-environment-driven configuration, point at a module implementing AshAuthentication.Secret: rp_name MyApp.Secrets | |
origin | String.t | (any -> any) | mfa | (any, any -> any) | module | The expected origin for WebAuthn ceremonies. In WebAuthn, the origin is the scheme + domain + port that the browser reports during registration and authentication. It is distinct from rp_id: - rp_id = domain only (e.g. "example.com") - origin = full URL (e.g. "https://example.com" or "https://localhost:4001") If not set, defaults to "https://{rp_id}". This default omits the port, which works for production on port 443 but will cause Wax to reject ceremonies in development where the port is non-standard. Production: origin "https://example.com" Development (non-standard port): origin "https://localhost:4001" Dynamic (multi-tenant): origin {MyApp.WebAuthn, :origin_for_tenant, []} Application-environment-driven: origin MyApp.Secrets | |
identity_field | atom | :email | The name of the attribute which uniquely identifies the user (e.g. :email). Used for looking up the user during authentication. |
authenticator_attachment | nil | :platform | :cross_platform | Restricts authenticator type. nil allows any, :platform limits to built-in (Touch ID, Windows Hello), :cross_platform limits to USB/NFC keys (YubiKey). | |
user_verification | "required" | "preferred" | "discouraged" | "preferred" | Whether user verification (PIN/biometric) is required. Use "required" for highest security. |
attestation | "none" | "direct" | "none" | Attestation conveyance preference. "none" is recommended for most use cases. "direct" requests the authenticator's attestation certificate. |
timeout | pos_integer | 60000 | Timeout for WebAuthn ceremonies in milliseconds. |
resident_key | :required | :preferred | :discouraged | :required | Whether to require discoverable credentials (passkeys). :required enables username-less authentication. |
credential_id_field | atom | :credential_id | The name of the credential ID attribute on the credential resource. |
public_key_field | atom | :public_key | The name of the public key attribute on the credential resource. |
sign_count_field | atom | :sign_count | The name of the sign count attribute on the credential resource. |
label_field | atom | :label | The name of the label attribute on the credential resource. |
last_used_at_field | atom | :last_used_at | The name of the last_used_at attribute on the credential resource. Set to nil to disable tracking. |
user_relationship_name | atom | :user | The name of the belongs_to relationship on the credential resource pointing to the user. |
credentials_relationship_name | atom | :webauthn_credentials | The name of the has_many relationship on the user resource pointing to credentials. |
registration_enabled? | boolean | true | Whether to allow new user registration via WebAuthn. |
sign_in_enabled? | boolean | true | Whether the strategy can sign users in directly (i.e. WebAuthn is the primary credential). Set to false when using WebAuthn purely as a second factor. |
verify_enabled? | boolean | true | Whether the strategy exposes a :verify phase that proves possession of a passkey for an already-authenticated user. Used for second-factor and step-up flows. |
register_action_name | atom | The name of the register action on the user resource. Defaults to register_with_<strategy_name>. | |
sign_in_action_name | atom | The name of the sign-in action on the user resource. Defaults to sign_in_with_<strategy_name>. | |
sign_in_with_token_action_name | atom | The name of the action used to sign in with a short-lived token issued by a successful WebAuthn ceremony. Defaults to sign_in_with_<strategy_name>_token. | |
verify_action_name | atom | The name of the second-factor verify action on the user resource. Defaults to verify_<strategy_name>. | |
store_credential_action_name | atom | The name of the create action on the credential resource. Defaults to store_<strategy_name>_credential. | |
update_sign_count_action_name | atom | The name of the update action for signcount on the credential resource. Defaults to `update<strategy_name>_sign_count`. | |
list_credentials_action_name | atom | The name of the read action to list credentials. Defaults to list_<strategy_name>_credentials. | |
delete_credential_action_name | atom | The name of the destroy action for credentials. Defaults to delete_<strategy_name>_credential. | |
update_credential_label_action_name | atom | The name of the update action for credential labels. Defaults to update_<strategy_name>_credential_label. | |
add_credential_action_name | atom | The name of the action to add a credential to an existing user. Defaults to add_<strategy_name>_credential. |