Passkey

Register and authenticate WebAuthn passkeys.

This page documents the current Ruby port behavior. Ruby uses snake_case option names and auth.api method names; HTTP paths and JSON keys keep the upstream wire shape where implemented.

Configure

config/auth.rb
require "better_auth"
require "better_auth/passkey"

auth = BetterAuth.auth(
  secret: ENV.fetch("BETTER_AUTH_SECRET"),
  base_url: ENV.fetch("BETTER_AUTH_URL", "http://localhost:3000"),
  plugins: [
    BetterAuth::Plugins.passkey(rp_name: "Example App", rp_id: "localhost", origin: "http://localhost:3000")
  ]
)

Usage

server.rb
options = auth.api.generate_passkey_registration_options(headers: { "cookie" => request.env["HTTP_COOKIE"] })
passkey = auth.api.verify_passkey_registration(headers: { "cookie" => request.env["HTTP_COOKIE"] }, body: { response: webauthn_response_from_browser })

Routes

MethodPathRuby API method
GET/passkey/generate-register-optionsauth.api.generate_passkey_registration_options
GET/passkey/generate-authenticate-optionsauth.api.generate_passkey_authentication_options
POST/passkey/verify-registrationauth.api.verify_passkey_registration
POST/passkey/verify-authenticationauth.api.verify_passkey_authentication
GET/passkey/list-user-passkeysauth.api.list_passkeys
POST/passkey/update-passkeyauth.api.update_passkey
POST/passkey/delete-passkeyauth.api.delete_passkey

Options

Current Ruby options accepted by BetterAuth::Plugins.passkey:

  • rp_id: WebAuthn relying party ID. Defaults to the configured base_url host.
  • rp_name: WebAuthn relying party name. Defaults to the Better Auth app name.
  • origin: allowed WebAuthn origin or array of origins. Defaults to base_url; verification also uses the request Origin header.
  • authenticator_selection: maps to WebAuthn authenticatorSelection. Supports resident_key, user_verification, and authenticator_attachment.
  • advanced.web_authn_challenge_cookie: challenge cookie name. Defaults to better-auth-passkey.
  • registration: registration-specific callbacks and WebAuthn extensions.
  • authentication: authentication-specific callbacks and WebAuthn extensions.
  • schema: passkey schema overrides. The Ruby gem deep-merges overrides with built-in fields and normalizes camelCase keys such as publicKey.

registration supports require_session, resolve_user, after_verification, and extensions. Set require_session: false for passkey-first registration and provide resolve_user when no session is present.

authentication supports extensions and after_verification.

Ruby option and method names are snake_case. HTTP paths and JSON keys remain compatible with upstream passkey server behavior. The SQL model uses the Ruby adapter table name passkeys.

Passkey-First Registration

Set registration.require_session to false when the user is not already signed in. Use context in the registration-options request to pass an application token or flow identifier into resolve_user and after_verification.

config/auth.rb
BetterAuth::Plugins.passkey(
  registration: {
    require_session: false,
    resolve_user: lambda do |data|
      invitation = Invitations.verify!(data.fetch(:context))
      {
        id: invitation.user_id,
        name: invitation.email,
        display_name: invitation.name,
        email: invitation.email
      }
    end,
    after_verification: lambda do |data|
      Audit.passkey_registered!(
        user_id: data.fetch(:user).fetch(:id),
        context: data.fetch(:context)
      )
      nil
    end
  }
)
server.rb
options = auth.api.generate_passkey_registration_options(
  query: { context: invitation_token }
)

after_verification may return { user_id: "..." } during passkey-first registration to link the verified passkey to a concrete user. Returning nil or "" leaves the resolved user unchanged. Returning any other non-empty-string value (integer, boolean, etc.) raises RESOLVED_USER_INVALID. During session-required registration, returning a different user ID is rejected with YOU_ARE_NOT_ALLOWED_TO_REGISTER_THIS_PASSKEY.

WebAuthn Extensions

Static extension data:

BetterAuth::Plugins.passkey(
  registration: {
    extensions: { credProps: true }
  },
  authentication: {
    extensions: { appid: "https://legacy.example.com" }
  }
)

Dynamic extension data:

BetterAuth::Plugins.passkey(
  registration: {
    extensions: ->(data) { { credProps: true, txAuthSimple: data.fetch(:ctx).headers["x-passkey-purpose"] } }
  },
  authentication: {
    extensions: ->(_data) { { hmacGetSecret: true } }
  }
)

Browser Client Scope

The Ruby gem provides server WebAuthn routes. It does not implement @better-auth/passkey/client, passkeyClient, startRegistration, startAuthentication, conditional UI, autofill, or browser extension-result handling. Ruby applications should call the browser WebAuthn APIs directly or wrap them in their own JavaScript client.

WebAuthn Configuration

The plugin configures rp_id, rp_name, and allowed origins per request with WebAuthn::RelyingParty. It does not mutate global WebAuthn.configuration, so multiple Better Auth instances can use different relying-party settings in the same process.

Upstream Parity Notes

  • excludeCredentials entries (registration options) are emitted as {id, transports?} to match upstream's @simplewebauthn/server output. allowCredentials (authentication options) still includes type: "public-key".
  • transports is omitted from credential descriptors when the stored value is missing or empty (rather than an empty array).
  • The default storage table is passkeys (plural) in the SQL adapters, mapped from the upstream passkey model. This is an intentional Ruby-specific adaptation.
  • credentialID is unique in the Ruby schema. This is intentional hardening beyond upstream v1.6.9 and prevents storing the same WebAuthn credential more than once.
  • rp_id resolution uses URI.parse(base_url).host (port stripped). When base_url is empty or unparseable, rp_id defaults to "localhost".
  • update_passkey accepts name: "" to match upstream z.string(). Missing or non-string name still raises VALIDATION_ERROR.
  • Cross-user delete_passkey raises UNAUTHORIZED with the PASSKEY_NOT_FOUND message, mirroring upstream's requireResourceOwnership middleware behavior when only notFoundError is configured.
  • Existing databases should deduplicate historical credential_id values before adding the unique constraint during migration.

Support Notes

  • Install better_auth-passkey; core better_auth only ships a compatibility shim for BetterAuth::Plugins.passkey.
  • The examples above are based on Ruby plugin source and tests in packages/better_auth-passkey.

On this page