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
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
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
| Method | Path | Ruby API method |
|---|---|---|
GET | /passkey/generate-register-options | auth.api.generate_passkey_registration_options |
GET | /passkey/generate-authenticate-options | auth.api.generate_passkey_authentication_options |
POST | /passkey/verify-registration | auth.api.verify_passkey_registration |
POST | /passkey/verify-authentication | auth.api.verify_passkey_authentication |
GET | /passkey/list-user-passkeys | auth.api.list_passkeys |
POST | /passkey/update-passkey | auth.api.update_passkey |
POST | /passkey/delete-passkey | auth.api.delete_passkey |
Options
Current Ruby options accepted by BetterAuth::Plugins.passkey:
rp_id: WebAuthn relying party ID. Defaults to the configuredbase_urlhost.rp_name: WebAuthn relying party name. Defaults to the Better Auth app name.origin: allowed WebAuthn origin or array of origins. Defaults tobase_url; verification also uses the requestOriginheader.authenticator_selection: maps to WebAuthnauthenticatorSelection. Supportsresident_key,user_verification, andauthenticator_attachment.advanced.web_authn_challenge_cookie: challenge cookie name. Defaults tobetter-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 aspublicKey.
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.
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
}
)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
excludeCredentialsentries (registration options) are emitted as{id, transports?}to match upstream's@simplewebauthn/serveroutput.allowCredentials(authentication options) still includestype: "public-key".transportsis 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 upstreampasskeymodel. This is an intentional Ruby-specific adaptation. credentialIDis 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_idresolution usesURI.parse(base_url).host(port stripped). Whenbase_urlis empty or unparseable,rp_iddefaults to"localhost".update_passkeyacceptsname: ""to match upstreamz.string(). Missing or non-stringnamestill raisesVALIDATION_ERROR.- Cross-user
delete_passkeyraisesUNAUTHORIZEDwith thePASSKEY_NOT_FOUNDmessage, mirroring upstream'srequireResourceOwnershipmiddleware behavior when onlynotFoundErroris configured. - Existing databases should deduplicate historical
credential_idvalues before adding the unique constraint during migration.
Support Notes
- Install
better_auth-passkey; corebetter_authonly ships a compatibility shim forBetterAuth::Plugins.passkey. - The examples above are based on Ruby plugin source and tests in
packages/better_auth-passkey.