Rails Passkeys: WebAuthn Passwordless Authentication with webauthn-ruby in Rails 8
Rails passkeys and WebAuthn in Rails 8: ship production passwordless authentication with webauthn-ruby, from registration flows to sign-in and recovery.
A client’s CISO called me last month with a specific request: “I want passwords gone from our admin console by the end of the quarter.” Their support queue was 30% password resets. Their SOC 2 auditor was pushing for phishing-resistant MFA. Their competitors were shipping passkey login and their sales team was getting asked about it in security reviews. The mandate was clear, and the deadline was ten weeks away. I opened the Rails console on their production replica, checked what auth library they were using — plain has_secure_password on top of the Rails 8 authentication generator — and started sketching the passkey rollout.
That project is why Rails passkeys are the auth topic I have spent the most time on in 2026. After nineteen years of Rails I have shipped every generation of authentication — HTTP Basic, Devise with 2FA, TOTP, magic links, SAML SSO — and passkeys are the first genuinely better primitive since bcrypt. They kill phishing, they eliminate password resets, and they work with the biometric hardware every one of your users already carries. This post is the exact Rails passkeys setup I ship, using webauthn-ruby on top of Rails 8’s built-in authentication.
What Rails Passkeys Actually Are
Rails passkeys are the Ruby-side implementation of the WebAuthn / FIDO2 standard, where a user’s private key lives on their device (Touch ID enclave, Windows Hello TPM, YubiKey, iCloud Keychain) and your server only ever stores the public key. Sign-in becomes a challenge-response: your Rails app sends a random challenge, the browser asks the authenticator to sign it, the signed response comes back, and webauthn-ruby verifies it against the stored public key.
Three properties make passkeys genuinely different from passwords or TOTP:
- They are phishing-resistant by construction. The browser binds every signature to your origin (
app.example.com). A phishing site atapp-example.comcannot get the authenticator to sign anything usable, no matter what a user clicks. Compare TOTP, which a convincing phishing site can proxy in real time. - There is no shared secret to steal from your database. Your
credentialstable holds only public keys and sign counters. A database dump gives an attacker exactly zero ability to sign in as any user. - They sync across the user’s devices. iCloud Keychain, Google Password Manager, and 1Password all sync passkeys across a user’s ecosystem. A user who registers a passkey on their iPhone can sign in on their Mac without registering again. This is the property that made passkeys go from “cool YubiKey demo” to “actually usable for consumer apps.”
The trade-off is that passkeys are a browser API. There is no way to hit a JSON endpoint with curl and log in. Your API clients will still need bearer tokens or the Rails 8 authentication sessions you already have. Passkeys replace the interactive human login flow, not the machine-to-machine one.
Setting Up webauthn-ruby in Rails 8
The gem is webauthn, currently version 3.x, and it targets the WebAuthn Level 3 spec including passkey-specific features. Add it to the Gemfile:
# Gemfile
gem "webauthn", "~> 3.0"
Then generate an initializer that configures your Relying Party — the identity your Rails app presents to the authenticator:
# config/initializers/webauthn.rb
WebAuthn.configure do |config|
config.origin = Rails.env.production? ? "https://app.example.com" : "http://localhost:3000"
config.rp_name = "Example App"
config.rp_id = Rails.env.production? ? "example.com" : "localhost"
config.credential_options_timeout = 120_000
config.silent_authentication = false
config.algorithms = %w[ES256 RS256 EdDSA]
end
Two config values are load-bearing here:
originmust exactly match the browser origin, including scheme and port. A mismatch produces aWebAuthn::OriginVerificationErrorat verification time, with no clue in the browser console.rp_idis the eTLD+1 that owns the passkey. Setting it toexample.com(notapp.example.com) lets the same passkey work across subdomains, which is what you want for a multi-tenant SaaS with per-account subdomains.
The user-facing model needs a table for stored credentials. I use the migration below on every Rails passkeys rollout:
class CreateWebauthnCredentials < ActiveRecord::Migration[8.0]
def change
create_table :webauthn_credentials do |t|
t.references :user, null: false, foreign_key: true, index: true
t.string :external_id, null: false
t.string :public_key, null: false
t.string :nickname, null: false
t.bigint :sign_count, null: false, default: 0
t.string :transports, array: true, default: []
t.datetime :last_used_at
t.timestamps
end
add_index :webauthn_credentials, :external_id, unique: true
end
end
The external_id is what the authenticator returns as its credential identifier — it is opaque to us, but we look up rows by it during sign-in. The sign_count is a monotonic counter the authenticator increments on every signature; we check it to detect cloned credentials.
The Registration Flow
Registration happens in two round trips: the server issues a challenge, the browser calls navigator.credentials.create(), and the browser posts the result back for verification. Here is the controller:
class WebauthnRegistrationsController < ApplicationController
before_action :require_authentication
def create_options
options = WebAuthn::Credential.options_for_create(
user: {
id: Current.user.webauthn_id,
name: Current.user.email,
display_name: Current.user.name
},
exclude: Current.user.webauthn_credentials.pluck(:external_id),
authenticator_selection: {
resident_key: "required",
user_verification: "required"
}
)
session[:webauthn_registration_challenge] = options.challenge
render json: options
end
def create
webauthn_credential = WebAuthn::Credential.from_create(params[:credential])
webauthn_credential.verify(session.delete(:webauthn_registration_challenge))
Current.user.webauthn_credentials.create!(
external_id: webauthn_credential.id,
public_key: webauthn_credential.public_key,
sign_count: webauthn_credential.sign_count,
nickname: params[:nickname].presence || "Passkey (#{Time.current.strftime('%b %-d')})",
transports: webauthn_credential.response.transports || []
)
render json: { status: "ok" }
rescue WebAuthn::Error => e
Rails.logger.warn("Passkey registration failed: #{e.class} #{e.message}")
render json: { error: "Registration failed" }, status: :unprocessable_entity
end
end
Two subtleties in that code that took me longer than they should have to figure out:
resident_key: "required"tells the authenticator to store the credential locally with a discoverable handle, so users can sign in without first typing their email. This is what makes a “click Sign In and pick your passkey” flow possible. Without it you are back to identifier-first flows.user_verification: "required"demands the authenticator prove the user is present — biometric or PIN. This is what turns the passkey into a phishing-resistant MFA factor. Setting it to"preferred"or"discouraged"opens the door to unattended signing, which most compliance auditors will reject.
The webauthn_id on the user is a stable, unique, non-PII identifier that the authenticator uses to distinguish accounts. I add it to the user model as a base64-encoded random ID:
class User < ApplicationRecord
has_many :webauthn_credentials, dependent: :destroy
before_create :set_webauthn_id
private
def set_webauthn_id
self.webauthn_id ||= WebAuthn.generate_user_id
end
end
Storing the user’s email or database ID as webauthn_id is a common mistake. It leaks PII to the authenticator (which may sync it across a user’s devices), and it makes it impossible to safely delete and recreate the user without invalidating every passkey. A random opaque ID sidesteps both problems.
The Sign-In Flow
Sign-in mirrors registration: options endpoint, browser call, verification endpoint. The interesting difference is that we do not know who the user is yet — we look them up from the credential ID that comes back:
class WebauthnSessionsController < ApplicationController
def create_options
options = WebAuthn::Credential.options_for_get(
user_verification: "required"
)
session[:webauthn_authentication_challenge] = options.challenge
render json: options
end
def create
webauthn_credential = WebAuthn::Credential.from_get(params[:credential])
stored = WebauthnCredential.find_by!(external_id: webauthn_credential.id)
webauthn_credential.verify(
session.delete(:webauthn_authentication_challenge),
public_key: stored.public_key,
sign_count: stored.sign_count
)
stored.update!(
sign_count: webauthn_credential.sign_count,
last_used_at: Time.current
)
start_new_session_for(stored.user)
render json: { redirect: after_authentication_url }
rescue ActiveRecord::RecordNotFound, WebAuthn::Error => e
Rails.logger.warn("Passkey sign-in failed: #{e.class} #{e.message}")
render json: { error: "Authentication failed" }, status: :unauthorized
end
end
The sign_count check inside verify is the built-in clone detection. If the authenticator sends a counter equal to or lower than what we stored, webauthn-ruby raises WebAuthn::SignCountVerificationError. On a genuine authenticator the counter is monotonic; a duplicated credential returns a stale counter and gets rejected. In practice iCloud-synced passkeys often send a counter of zero (the sync protocol does not track counters across devices), so most teams treat a zero counter as informational rather than blocking — the current webauthn-ruby default is to only reject decreases, not equal values, which matches Apple’s guidance.
The one bit of Rails 8 authentication integration is start_new_session_for, which is the method the Rails 8 authentication generator ships with. It creates a session and sets the signed cookie. If you rolled your own session handling, this is where you plug it in.
The JavaScript You Actually Ship
Every WebAuthn tutorial online assumes you will hand-write the navigator.credentials.* glue. Do not. Base64 encoding/decoding of the challenge and credential ID is the number-one source of subtle bugs, and there is a maintained library that handles it: @github/webauthn-json. It normalizes the JSON so the Ruby side and the browser side speak the same shape.
Add it via importmap:
# config/importmap.rb
pin "@github/webauthn-json", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.js"
Then a Stimulus controller for the sign-in button:
// app/javascript/controllers/passkey_signin_controller.js
import { Controller } from "@hotwired/stimulus"
import { get } from "@github/webauthn-json"
export default class extends Controller {
async signIn(event) {
event.preventDefault()
const optionsResponse = await fetch("/webauthn/sessions/options", {
method: "POST",
headers: { "X-CSRF-Token": this.csrfToken(), "Content-Type": "application/json" }
})
const options = await optionsResponse.json()
let credential
try {
credential = await get({ publicKey: options })
} catch (err) {
console.warn("Passkey prompt cancelled or failed", err)
return
}
const verifyResponse = await fetch("/webauthn/sessions", {
method: "POST",
headers: { "X-CSRF-Token": this.csrfToken(), "Content-Type": "application/json" },
body: JSON.stringify({ credential })
})
if (verifyResponse.ok) {
const { redirect } = await verifyResponse.json()
window.location.href = redirect
} else {
this.showError("Sign-in failed. Try again or use your password.")
}
}
csrfToken() {
return document.querySelector('meta[name="csrf-token"]').content
}
}
The one UX pattern that took me a while to get right: get({ publicKey: options }) throws when the user dismisses the browser prompt, and that is not an error worth alerting on. Users routinely cancel because they picked the wrong finger or realized they wanted a different device. Log it, do not surface it.
Password Fallback and Progressive Rollout
Passkeys work on 96% of the browsers your users have, but “works” is not the same as “the user has set one up.” My rollout plan on every Rails passkeys client project has four stages:
- Passkey as second factor. Ship it as an optional TOTP replacement first. Users still sign in with password + passkey (or password + TOTP if they haven’t registered a passkey). This proves your registration and sign-in flows work under real load with real support requests.
- Passkey-first sign-in. The sign-in page defaults to a “Sign in with a passkey” button. Users without a passkey click “Use password instead” and get the old flow. Log the ratio; you want to see it climbing.
- Passkey-required for new accounts. New signups must register at least one passkey. Password becomes a backup credential, not the primary.
- Password removal. Once >90% of active users have a passkey and 30 days of dashboard data show no passkey-related support tickets, the password field goes away. Users without a passkey get an email-based recovery flow (“send me a magic link, then register a passkey”).
Rushing this timeline is how you get a support-queue disaster. The client from the opening story is at stage 3 as of last week; stage 4 is scheduled for Q1 2027 after the December support-volume dip.
Observability and Failure Modes
Ship Rails passkeys with instrumentation from day one. The failure modes are subtle and the errors browsers surface to users are not. I emit four counters via Sentry and StatsD:
passkey.registration.successandpasskey.registration.failure(with the exception class as a tag).passkey.signin.successandpasskey.signin.failure(same tag scheme).passkey.signin.credential_not_found— a specific subclass of failure. A spike here usually means a user reset their device and lost their passkeys, or you deployed a code change that broke the credential ID lookup.passkey.signin.sign_count_mismatch— if this ever rises above the baseline of iCloud-synced zeros, an attacker may be replaying a captured credential.
On the client project I currently support, the ongoing metric that has proved most useful is passkey.signin.duration_ms. It is roughly 800 ms end-to-end (mostly biometric prompt time), and any deviation above 2 seconds correlates with backend latency issues that would otherwise get lost in the general auth traffic.
The Payoff
Ten weeks after that first CISO call, the client had passkeys live in production. Six weeks after that, password reset support tickets were down 70%. The SOC 2 auditor closed the phishing-resistant MFA finding. And in the last three sales calls I sat in on, the security reviewer asked “do you support passkeys?” and the answer was yes. That is the return Rails passkeys deliver: better security, less support load, and a competitive answer to a question that is being asked in every enterprise buying process in 2026.
FAQ
Do Rails passkeys work in Safari on iOS and macOS?
Yes, and this is where they shine. Safari on iOS 16+ and macOS Ventura+ supports WebAuthn with iCloud Keychain sync, which means a passkey registered on an iPhone works on a Mac and iPad without re-enrollment. On Safari specifically, make sure your rp_id matches an eTLD+1 that the browser trusts — the associated-domains file is not required for basic passkey flows, only for cross-app / autofill integration.
Can I use passkeys as a second factor alongside a password?
Yes. Set user_verification: "required" in your create-options call and treat a successful passkey verification as satisfying your MFA requirement. This is exactly stage 1 of the rollout plan above and is the safest starting point. webauthn-ruby does not care whether the passkey is a first factor or a second factor — that policy lives entirely in your controller.
How do I let users delete or rename their passkeys?
Give the user a settings page that lists their webauthn_credentials and lets them delete or rename each row. The nickname field on the credential is what they see; store something readable (“iPhone 15”, “YubiKey NFC”) rather than the raw credential ID. Deleting the row is enough — the browser and OS still hold the private key, but your Rails app will refuse to authenticate it. Encourage users to also remove it from their password manager UI for cleanliness.
What happens if a user loses every device with their passkey?
You need a recovery path that is not a passkey. The three patterns I have shipped: (1) an emailed magic link that lets them register a new passkey after clicking it, gated behind a rate limit, (2) a set of one-time recovery codes shown at registration and stored by the user in a password manager, and (3) SSO fallback via Google or Microsoft for accounts that were provisioned through SSO in the first place. Whichever you pick, the recovery path is the weakest link in your entire authentication story — it deserves the same security review as the passkey flow itself.
Rolling out passkeys in a Rails app and want to make sure the flow, the fallbacks, and the recovery paths all hold up under real users? TTB Software helps teams ship passwordless authentication end-to-end, from webauthn-ruby integration to the SOC 2 conversation. Nineteen years of Rails, and this is the auth primitive I have been waiting for.
Related Articles
Rails PostgreSQL Row-Level Security: Multi-Tenant SaaS Isolation with RLS Policies
Rails PostgreSQL Row-Level Security for multi-tenant SaaS: how to implement RLS policies, session variables, and safe...
Rails 8 Solid Cache: Production Setup, TTL Strategies, and Migrating from Redis
Rails 8 Solid Cache in production: setup guide with database sizing, TTL and eviction strategies, migrating from Redi...
Rails Thruster: Replace Nginx with Rails 8's Built-In HTTP/2 Proxy in Production
Rails Thruster replaces Nginx as your HTTP/2 proxy in Rails 8 production. Configuration guide: TLS via ACME, compress...