passkeyed
Passwordless, usernameless passkey (WebAuthn) authentication for Rails, built on webauthn-ruby. No mountable engine hiding the flow: you get a model concern, a controller concern exposing four methods, and a Stimulus controller — you write your own routes, controllers, and views.
A passkey is a public/private key pair created for one site. The private key never leaves the user's device, your server stores only the public half, and the browser binds each credential to its origin — nothing to phish, nothing worth stealing from your database.
Companion to a two-part series: Passkeys from first principles (what passkeys are and how the ceremonies work) and Building passkeys in Ruby on Rails (the from-scratch build this gem packages).
Installation
gem "passkeyed"
bundle install
bin/rails generate passkeyed:install
bin/rails db:migrate
The generator adds the passkeyed_credentials table, a webauthn_id column
on your owner model (backfilled for existing rows, so current users can
register passkeys immediately), an initializer, the Stimulus controller, and
include Passkeyed::Model in your model.
There is no JavaScript dependency: the Stimulus controller uses the browser's
native PublicKeyCredential JSON methods
(shipped in every major browser)
and reports passkeys as unavailable on a browser too old to have them.
Configuration
config/initializers/passkeyed.rb:
Passkeyed.configure do |config|
config.rp_name = "Acme"
config.rp_id = "acme.example" # optional in dev
config.allowed_origins = ["https://acme.example"] # required when deployed
config.user_verification = "required"
config.challenge_timeout = 300 # seconds a challenge stays valid
end
In any environment other than development and test, boot fails with a
Passkeyed::ConfigurationError when allowed_origins is blank or non-HTTPS
(WebAuthn requires a secure context), so the generated localhost default can't
silently ship. passkeyed never writes to webauthn-ruby's global configuration —
it hands an isolated WebAuthn::RelyingParty to every call — so an app using
webauthn-ruby directly keeps its own settings.
Usage
Mix the ceremony helpers into a controller. Each ceremony is a challenge that the authenticator signs and the server verifies.
class RegistrationsController < ApplicationController
include Passkeyed::Ceremonies
# POST /passkeys/registration_options
def
render json: (current_user)
end
# POST /passkeys
def create
passkey_register!(current_user, params[:credential], nickname: params[:nickname])
render json: { status: "ok" }
rescue Passkeyed::Error => e
render json: { error: e. }, status: :unprocessable_entity
end
end
class SessionsController < ApplicationController
include Passkeyed::Ceremonies
# POST /session/options
def
render json:
end
# POST /session
def create
user = passkey_authenticate!(params[:credential])
session[:user_id] = user.id
render json: { status: "ok" }
rescue Passkeyed::Error
render json: { error: "Authentication failed" }, status: :unauthorized
end
end
Wire the Stimulus controller in your views. To register a passkey (the optional
nickname input lets the user label the device):
<div data-controller="passkey"
data-passkey-registration-options-url-value="<%= options_passkeys_path %>"
data-passkey-registration-url-value="<%= passkeys_path %>"
data-passkey-redirect-url-value="<%= dashboard_path %>">
<input data-passkey-target="nickname" placeholder="Laptop">
<button data-action="passkey#register">Add a passkey</button>
<p data-passkey-target="error" role="alert"></p>
</div>
To sign in:
<div data-controller="passkey"
data-passkey-authentication-options-url-value="<%= options_session_path %>"
data-passkey-authentication-url-value="<%= session_path %>"
data-passkey-redirect-url-value="<%= dashboard_path %>">
<button data-action="passkey#authenticate">Sign in with a passkey</button>
<p data-passkey-target="error" role="alert"></p>
</div>
(role="alert" makes screen readers announce a ceremony failure.)
Conditional UI (passkey autofill)
Add data-passkey-conditional-value="true" and an input marked
autocomplete="username webauthn" to also offer passkeys through the
browser's autofill:
<div data-controller="passkey"
data-passkey-conditional-value="true"
data-passkey-authentication-options-url-value="<%= options_session_path %>"
data-passkey-authentication-url-value="<%= session_path %>"
data-passkey-redirect-url-value="<%= dashboard_path %>">
<input type="text" autocomplete="username webauthn" placeholder="you@example.com">
<button data-action="passkey#authenticate">Sign in with a passkey</button>
<p data-passkey-target="error" role="alert"></p>
</div>
The feature is skipped where the browser lacks it, a modal ceremony or navigation aborts the pending request cleanly, and a failed attempt (a dismissed sheet, an expired challenge) re-arms autofill automatically.
Reacting to a ceremony from JavaScript
After a successful ceremony the controller dispatches a cancelable
passkey:registered / passkey:authenticated event (detail: the server's
JSON response) before following data-passkey-redirect-url-value (or
reloading). Call event.preventDefault() to take over — e.g. to update the
page with Turbo. Failures dispatch passkey:error.
Public API
| Method | Purpose |
|---|---|
Passkeyed::Model |
Include in the owner model: credentials association + webauthn_id. |
passkey_registration_options(user) |
Build creation options (discoverable), stash the challenge. |
passkey_register!(user, credential, nickname:) |
Verify and persist a new credential. |
passkey_authentication_options |
Build request options (no allow-list), stash the challenge. |
passkey_authenticate!(credential) |
Verify an assertion and return the owner. |
All ceremony failures raise Passkeyed::Error or a subclass
(RegistrationError, AuthenticationError, CredentialNotFound,
ChallengeMissing, ChallengeExpired).
Managing a user's passkeys
List, rename, and revoke through the owner, so a mismatched id raises
ActiveRecord::RecordNotFound instead of touching another account's
credential:
current_user.passkeyed_credentials # list (id, nickname, created_at, ...)
current_user.rename_passkey(id, "Work Laptop")
current_user.revoke_passkey(id) # guard the last passkey in your app
Each credential also records metadata for richer management UIs:
| Column | Meaning |
|---|---|
backed_up / backup_eligible |
Synced passkey (iCloud Keychain, Google Password Manager) vs device-bound. Refreshed on every sign-in. |
transports |
How the authenticator talks to clients, e.g. ["internal", "hybrid"]. |
aaguid |
The authenticator model's UUID (nil when zeroed); map to names via the community AAGUID list. |
last_used_at |
Stamped on every successful assertion. |
The user entity sent at registration uses passkey_name (an email by default)
and passkey_display_name (defaults to passkey_name). Override either in
your model.
Hooks
Override these private no-op methods in your controller to run after a successful ceremony:
def after_passkey_authentication(credential, user)
AuditLog.record(:passkey_sign_in, user:, credential:)
end
after_passkey_registration(credential) is the registration counterpart. Both
run with full controller context and outside the ceremony's error handling, so
an exception you raise surfaces as itself.
Instrumentation
Both bang methods emit ActiveSupport::Notifications events —
register.passkeyed and authenticate.passkeyed — with :credential/:user
in the payload and, unlike the success-only hooks, the standard :exception
keys on failure, so probing is observable:
ActiveSupport::Notifications.subscribe("authenticate.passkeyed") do |event|
next unless event.payload[:exception]
Rails.logger.warn("passkey sign-in failed: #{event.payload[:exception_object]&.}")
end
Notes and non-goals
- Signature counter. A non-increasing
sign_countfrom an authenticator that reports nonzero counts raisesPasskeyed::AuthenticationError— the classic cloned-authenticator signal. Synced passkeys report zero on both sides and are exempt. - Return one generic message for failed sign-ins, as the example above
does: rescue
Passkeyed::AuthenticationError(whichCredentialNotFoundsubclasses) so you don't reveal whether a credential id is on record. Response timing can still hint at it — an unknown id skips the signature check — so pair the generic message with rate limiting. - Rate-limit the ceremony endpoints. They are unauthenticated and run
public-key crypto on every request. On Rails 8+:
rate_limit to: 10, within: 1.minute, only: %i[options create]; on older Rails, userack-attack. - Sessions and CSRF. Challenges are stashed in
session, and the Stimulus controller sends the CSRF token from thecsrf_meta_tagsmeta tag — both standard in a full-stack Rails app. In an API-only app, add session middleware (or overridepasskeyed_session) and your own request-authenticity scheme. - Account recovery is your responsibility: plan for the user who loses their device (a second passkey, or an out-of-band recovery path).
- Out of scope: password fallback, passkeys-as-second-factor, attestation verification, and Devise integration.
Development
bin/setup
bundle exec rake test # Minitest; ceremonies driven by WebAuthn::FakeClient
node --test test/javascript/*.test.mjs # Stimulus controller tests, no npm install
CI also runs the suite against the oldest supported Rails:
BUNDLE_GEMFILE=gemfiles/rails_7.1.gemfile bundle exec rake test.
License
MIT.