AshAuthentication.Strategy.WebAuthn

Copy Markdown View Source

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_id per 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, a webauthn_verified_at claim 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
end

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

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

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
end

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

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

  1. Generates a registration challenge (via Wax.new_registration_challenge/1)
  2. Sends it to the browser
  3. Receives the attestation response
  4. Calls Wax.register/3 to verify it
  5. 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
end

Then 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"
end

Gotchas

  • Origin must include the port for non-standard ports (e.g., "https://localhost:4001"). The default derivation from rp_id produces "https://{rp_id}" which omits the port.
  • Signing secret must return {:ok, value}, not a raw string. A common mistake is fn _, _ -> "my_secret" end -- it must be fn _, _ -> {:ok, "my_secret"} end.
  • Challenge data is stored in the session as plain maps, not Wax.Challenge structs, 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. The register action creates a new user. See "Adding Credentials to Existing Users" above.
  • origin_verify_fun is 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_in via Jwt.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 \\ :webauthn

Strategy 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

NameTypeDefaultDocs
credential_resourceatom | moduleThe 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_idString.t | (any -> any) | mfa | (any, any -> any) | moduleRelying 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_nameString.t | (any -> any) | mfa | (any, any -> any) | moduleRelying 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
originString.t | (any -> any) | mfa | (any, any -> any) | moduleThe 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_fieldatom:emailThe name of the attribute which uniquely identifies the user (e.g. :email). Used for looking up the user during authentication.
authenticator_attachmentnil | :platform | :cross_platformRestricts 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.
timeoutpos_integer60000Timeout for WebAuthn ceremonies in milliseconds.
resident_key:required | :preferred | :discouraged:requiredWhether to require discoverable credentials (passkeys). :required enables username-less authentication.
credential_id_fieldatom:credential_idThe name of the credential ID attribute on the credential resource.
public_key_fieldatom:public_keyThe name of the public key attribute on the credential resource.
sign_count_fieldatom:sign_countThe name of the sign count attribute on the credential resource.
label_fieldatom:labelThe name of the label attribute on the credential resource.
last_used_at_fieldatom:last_used_atThe name of the last_used_at attribute on the credential resource. Set to nil to disable tracking.
user_relationship_nameatom:userThe name of the belongs_to relationship on the credential resource pointing to the user.
credentials_relationship_nameatom:webauthn_credentialsThe name of the has_many relationship on the user resource pointing to credentials.
registration_enabled?booleantrueWhether to allow new user registration via WebAuthn.
sign_in_enabled?booleantrueWhether 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?booleantrueWhether 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_nameatomThe name of the register action on the user resource. Defaults to register_with_<strategy_name>.
sign_in_action_nameatomThe name of the sign-in action on the user resource. Defaults to sign_in_with_<strategy_name>.
sign_in_with_token_action_nameatomThe 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_nameatomThe name of the second-factor verify action on the user resource. Defaults to verify_<strategy_name>.
store_credential_action_nameatomThe name of the create action on the credential resource. Defaults to store_<strategy_name>_credential.
update_sign_count_action_nameatomThe name of the update action for signcount on the credential resource. Defaults to `update<strategy_name>_sign_count`.
list_credentials_action_nameatomThe name of the read action to list credentials. Defaults to list_<strategy_name>_credentials.
delete_credential_action_nameatomThe name of the destroy action for credentials. Defaults to delete_<strategy_name>_credential.
update_credential_label_action_nameatomThe name of the update action for credential labels. Defaults to update_<strategy_name>_credential_label.
add_credential_action_nameatomThe name of the action to add a credential to an existing user. Defaults to add_<strategy_name>_credential.

Introspection

Target: AshAuthentication.Strategy.WebAuthn