AshAuthentication.Strategy.WebAuthn (ash_authentication v5.0.0-rc.6)

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.

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.

Summary

Types

t()

@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

transform(entity, dsl_state)

Callback implementation for AshAuthentication.Strategy.Custom.transform/2.

verify(strategy, dsl_state)

Callback implementation for AshAuthentication.Strategy.Custom.verify/2.