better_auth-passkey
Passkey/WebAuthn plugin package for Better Auth Ruby.
Installation
Add the gem and require the package before configuring the plugin:
gem "better_auth-passkey"
require "better_auth/passkey"
auth = BetterAuth.auth(
secret: ENV.fetch("BETTER_AUTH_SECRET"),
database: :memory,
plugins: [
BetterAuth::Plugins.passkey(
rp_id: "localhost",
rp_name: "Example App",
origin: "http://localhost:3000"
)
]
)
Options
BetterAuth::Plugins.passkey accepts Ruby snake_case options:
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.authenticator_selection: supportsresident_key,user_verification, andauthenticator_attachment.advanced.web_authn_challenge_cookie: challenge cookie name. Defaults tobetter-auth-passkey.registration: supportsrequire_session,resolve_user,after_verification, andextensions.authentication: supportsafter_verificationandextensions.schema: deep-merged schema overrides. The built-in SQL table remainspasskeys, matching the Ruby adapter convention.
HTTP routes and wire JSON keys are kept compatible with upstream Better Auth passkey server behavior. Ruby method names and configuration keys remain idiomatic snake_case.
Passkey-first registration
Use require_session: false to register a passkey before a session exists:
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
}
)
Pass context when generating registration options:
auth.api.(query: { context: invitation_token })
During passkey-first registration, after_verification may return { user_id: "..." } to attach the credential to a concrete user. During session-required registration, switching users is rejected.
WebAuthn extensions
BetterAuth::Plugins.passkey(
registration: {
extensions: { credProps: true }
},
authentication: {
extensions: ->(_data) { { hmacGetSecret: true } }
}
)
Browser client scope
This gem provides server WebAuthn routes. It does not ship the upstream browser-only @better-auth/passkey/client helper, passkeyClient, startRegistration, startAuthentication, conditional UI, autofill, or extension-result handling. Use the browser WebAuthn APIs directly or wrap them in application JavaScript.
WebAuthn configuration
The plugin uses WebAuthn::RelyingParty per request for rp_id, rp_name, and allowed origins. It does not mutate global WebAuthn.configuration, so multiple Better Auth instances can use different relying-party settings in the same Ruby process.
Upstream parity notes
The Ruby plugin tracks Better Auth v1.6.9 upstream behavior. A few wire-shape and validation details are worth noting:
excludeCredentialsentries (registration options) are emitted as{id, transports?}to match upstream's@simplewebauthn/serveroutput.allowCredentials(authentication options) still includestype: "public-key"to mirror upstream's authentication wire shape.transportsis omitted entirely from credential descriptors when the stored value is missing or empty (rather than emitting an empty array).- The default storage table is named
passkeys(plural) in the SQL adapters, mapped from the upstreampasskeymodel. Custom SQL adapters that translate thepasskeymodel name continue to work. rp_idresolution falls back toURI.parse(base_url).host(port stripped). Whenbase_urlis empty or unparseable,rp_iddefaults to"localhost".- For passkey-first registration, the
after_verificationcallback may return{ user_id: nil }or{ user_id: "" }to leave the resolved user unchanged. Returning any other non-empty-string value (integer, boolean, etc.) raisesRESOLVED_USER_INVALID. update_passkeyaccepts an empty-stringnameto match upstreamz.string(). Missing or non-stringnamestill raisesVALIDATION_ERROR.- Cross-user
delete_passkeyraisesUNAUTHORIZEDwith thePASSKEY_NOT_FOUNDmessage, mirroring upstream'srequireResourceOwnershipmiddleware behavior when onlynotFoundErroris configured.
Notes
This package depends on the maintained webauthn gem. Keeping passkeys outside better_auth avoids installing WebAuthn dependencies for applications that do not use passkeys.