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.
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.
Summary
Functions
Callback implementation for AshAuthentication.Strategy.Custom.transform/2.
Callback implementation for AshAuthentication.Strategy.Custom.verify/2.
Types
@type t() :: %AshAuthentication.Strategy.WebAuthn{ __spark_metadata__: any(), add_credential_action_name: atom() | nil, attestation: String.t(), authenticator_attachment: nil | :platform | :cross_platform, credential_id_field: atom(), credential_resource: module(), credentials_relationship_name: atom(), delete_credential_action_name: atom() | nil, identity_field: atom(), label_field: atom(), last_used_at_field: atom() | nil, list_credentials_action_name: atom() | nil, name: atom(), origin: String.t() | {module(), atom(), list()} | nil, public_key_field: atom(), register_action_name: atom() | nil, registration_enabled?: boolean(), resident_key: :required | :preferred | :discouraged, resource: module(), rp_id: String.t() | {module(), atom(), list()}, rp_name: String.t() | {module(), atom(), list()}, sign_count_field: atom(), sign_in_action_name: atom() | nil, store_credential_action_name: atom() | nil, timeout: pos_integer(), update_credential_label_action_name: atom() | nil, update_sign_count_action_name: atom() | nil, user_relationship_name: atom(), user_verification: String.t() }
Functions
Callback implementation for AshAuthentication.Strategy.Custom.transform/2.
Callback implementation for AshAuthentication.Strategy.Custom.verify/2.