RUBY ON RAILS · 18 MIN READ ·

Rails Passkeys: WebAuthn Passwordless Authenticatie met webauthn-ruby in Rails 8

Rails passkeys en WebAuthn in Rails 8: bouw productie-passwordless authenticatie met webauthn-ruby, van registratieflows tot inloggen en herstel.

Rails Passkeys: WebAuthn Passwordless Authenticatie met webauthn-ruby in Rails 8

Vorige maand belde de CISO van een klant met één specifieke vraag: “Ik wil dat wachtwoorden voor het einde van dit kwartaal uit onze admin-omgeving verdwenen zijn.” Dertig procent van hun supportqueue bestond uit wachtwoord-resets. Hun SOC 2-auditor drong aan op phishing-bestendige MFA. Hun concurrenten hadden al passkey-login live en de sales-afdeling kreeg er in elk security review vragen over. De opdracht was helder, en de deadline lag tien weken verderop. Ik opende de Rails console op hun productie-replica, keek welke auth-library ze gebruikten — gewoon has_secure_password bovenop de Rails 8 authentication generator — en begon de passkey-uitrol te schetsen.

Dat project is waarom Rails passkeys het auth-onderwerp is waar ik in 2026 de meeste tijd in gestoken heb. Na negentien jaar Rails heb ik elke generatie authenticatie voorbij zien komen — HTTP Basic, Devise met 2FA, TOTP, magic links, SAML SSO — en passkeys zijn de eerste écht betere primitive sinds bcrypt. Ze doden phishing, ze elimineren wachtwoord-resets, en ze werken met de biometrische hardware die iedere gebruiker al in zijn broekzak heeft. Deze post is de exacte Rails passkeys-setup die ik uitrol, met webauthn-ruby bovenop de ingebouwde authenticatie van Rails 8.

Wat Rails Passkeys eigenlijk zijn

Rails passkeys zijn de Ruby-kant van de WebAuthn / FIDO2-standaard, waarbij de privésleutel van de gebruiker op zijn device leeft (Touch ID-enclave, Windows Hello TPM, YubiKey, iCloud Keychain) en jouw server alleen de publieke sleutel opslaat. Inloggen wordt een challenge-response: je Rails-app stuurt een willekeurige challenge, de browser vraagt de authenticator die te tekenen, de getekende response komt terug, en webauthn-ruby verifieert die tegen de opgeslagen publieke sleutel.

Drie eigenschappen maken passkeys wezenlijk anders dan wachtwoorden of TOTP:

  • Ze zijn phishing-bestendig by design. De browser bindt elke handtekening aan jouw origin (app.example.com). Een phishingsite op app-example.com krijgt de authenticator nooit zover om iets bruikbaars te tekenen, wat een gebruiker ook aanklikt. Vergelijk dat met TOTP, waar een overtuigende phishingsite de code live kan proxy’en.
  • Er valt geen gedeeld geheim uit je database te stelen. Je credentials-tabel bevat alleen publieke sleutels en sign counters. Een database-dump geeft een aanvaller precies nul mogelijkheden om als een gebruiker in te loggen.
  • Ze syncen tussen de devices van de gebruiker. iCloud Keychain, Google Password Manager en 1Password syncen passkeys allemaal binnen het ecosysteem van de gebruiker. Iemand die een passkey op zijn iPhone registreert, logt op zijn Mac in zonder opnieuw te registreren. Dát is de eigenschap die passkeys omtoverde van “leuke YubiKey-demo” naar “echt bruikbaar voor consumenten-apps.”

De trade-off is dat passkeys een browser-API zijn. Er is geen manier om met curl een JSON-endpoint te raken en in te loggen. Je API-clients hebben nog steeds bearer tokens of de Rails 8 authenticatie-sessies die je al hebt nodig. Passkeys vervangen de interactieve menselijke login, niet de machine-tot-machine-variant.

webauthn-ruby opzetten in Rails 8

De gem heet webauthn, staat momenteel op versie 3.x, en richt zich op de WebAuthn Level 3-spec inclusief passkey-specifieke features. Voeg hem toe aan de Gemfile:

# Gemfile
gem "webauthn", "~> 3.0"

Genereer daarna een initializer die je Relying Party configureert — de identiteit die je Rails-app aan de authenticator presenteert:

# 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

Twee configuratiewaarden zijn hier bepalend:

  • origin moet exact overeenkomen met de browser-origin, inclusief schema en poort. Een mismatch geeft bij verificatie een WebAuthn::OriginVerificationError, zonder aanwijzing in de browserconsole.
  • rp_id is de eTLD+1 die de passkey bezit. Door hem op example.com (niet app.example.com) te zetten kan dezelfde passkey op meerdere subdomeinen werken, wat je wilt voor een multi-tenant SaaS met per-account subdomeinen.

Het user-facing model heeft een tabel voor opgeslagen credentials nodig. Ik gebruik onderstaande migratie bij iedere Rails passkeys-uitrol:

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

De external_id is wat de authenticator teruggeeft als credential-identifier — voor ons is die opaak, maar we zoeken er tijdens sign-in rijen mee op. De sign_count is een monotone teller die de authenticator bij elke handtekening ophoogt; wij checken die om gekloonde credentials te detecteren.

De registratie-flow

Registratie gaat in twee rondes: de server geeft een challenge uit, de browser roept navigator.credentials.create() aan, en de browser post het resultaat terug voor verificatie. Zo ziet de controller eruit:

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

Twee subtiliteiten in die code die me langer kostten dan zou moeten:

  • resident_key: "required" vertelt de authenticator dat de credential lokaal opgeslagen moet worden met een discoverable handle, zodat gebruikers kunnen inloggen zonder eerst hun e-mail te typen. Dat maakt een “klik Inloggen en kies je passkey”-flow mogelijk. Zonder dit zit je terug in identifier-first flows.
  • user_verification: "required" eist dat de authenticator aantoont dat de gebruiker aanwezig is — biometrisch of via PIN. Dát maakt de passkey een phishing-bestendige MFA-factor. Zet je hem op "preferred" of "discouraged", dan zet je de deur open voor onbewaakt tekenen, wat de meeste compliance-auditors zullen afwijzen.

Het webauthn_id op de user is een stabiele, unieke, niet-PII-identifier die de authenticator gebruikt om accounts uit elkaar te houden. Ik voeg die toe aan het user-model als een 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

Het e-mailadres of database-ID van de gebruiker als webauthn_id opslaan is een klassieke fout. Het lekt PII naar de authenticator (die dat mogelijk over de devices van de gebruiker synct), en het maakt het onmogelijk om de user veilig te verwijderen en opnieuw aan te maken zonder alle passkeys te invalideren. Een willekeurige, opaque ID omzeilt beide problemen.

De sign-in flow

Inloggen spiegelt registratie: options-endpoint, browser-call, verificatie-endpoint. Het interessante verschil is dat we nog niet weten wie de gebruiker is — we zoeken die op via het credential-ID dat terugkomt:

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

De sign_count-check binnen verify is de ingebouwde clone-detectie. Als de authenticator een teller stuurt die gelijk is aan of lager dan wat wij opgeslagen hebben, gooit webauthn-ruby een WebAuthn::SignCountVerificationError. Op een echte authenticator is de teller monotoon; een gedupliceerde credential stuurt een verouderde teller en wordt afgewezen. In de praktijk sturen iCloud-gesyncte passkeys vaak een teller van nul (het sync-protocol houdt tellers niet bij tussen devices), dus de meeste teams behandelen een nul-teller als informatief in plaats van blokkerend — de huidige default van webauthn-ruby is om alleen dalingen te weigeren, niet gelijke waarden, wat aansluit bij Apple’s guidance.

De enige stukje Rails 8 authenticatie-integratie is start_new_session_for, de methode die de Rails 8 authentication generator meelevert. Die maakt een sessie aan en zet de signed cookie. Als je je eigen sessie-handling geschreven hebt, dan is dat de plek waar je inplugt.

De JavaScript die je écht ship’t

Elke WebAuthn-tutorial online gaat ervan uit dat je de navigator.credentials.*-glue met de hand schrijft. Doe dat niet. Base64-encoding/decoding van de challenge en credential-ID is bron één van subtiele bugs, en er is een onderhouden library die dat afhandelt: @github/webauthn-json. Die normaliseert de JSON zodat de Ruby-kant en de browser-kant dezelfde vorm spreken.

Voeg ‘m toe 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"

Vervolgens een Stimulus-controller voor de sign-in knop:

// 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
  }
}

Het ene UX-patroon dat me even kostte om goed te krijgen: get({ publicKey: options }) gooit een fout als de gebruiker de browserprompt wegklikt, en dat is geen fout waar je een alert voor moet tonen. Gebruikers annuleren routinematig omdat ze de verkeerde vinger kozen of doorhadden dat ze een ander device wilden. Log het, maar breng het niet in beeld.

Password-fallback en progressive rollout

Passkeys werken op 96% van de browsers die je gebruikers hebben, maar “werkt” is niet hetzelfde als “de gebruiker heeft er een aangemaakt.” Mijn uitrolplan bij elk Rails passkeys-project bestaat uit vier fasen:

  1. Passkey als tweede factor. Rol hem eerst uit als optionele TOTP-vervanger. Gebruikers loggen nog steeds in met wachtwoord + passkey (of wachtwoord + TOTP als ze geen passkey geregistreerd hebben). Zo bewijs je dat je registratie- en sign-in-flows overeind blijven onder echte belasting en echte support-vragen.
  2. Passkey-first inloggen. De sign-in pagina zet standaard een “Log in met passkey”-knop. Gebruikers zonder passkey klikken op “Gebruik wachtwoord” en krijgen de oude flow. Log de ratio; je wilt zien dat die stijgt.
  3. Passkey verplicht voor nieuwe accounts. Nieuwe signups moeten ten minste één passkey registreren. Wachtwoord wordt back-up credential, geen primary meer.
  4. Wachtwoord weg. Zodra >90% van de actieve gebruikers een passkey heeft en 30 dagen dashboard-data geen passkey-gerelateerde support-tickets tonen, verdwijnt het wachtwoord-veld. Gebruikers zonder passkey krijgen een e-mail-gebaseerde recovery-flow (“stuur me een magic link, dan registreer ik een passkey”).

Deze planning versnellen is hoe je een support-queue-ramp veroorzaakt. De klant uit het openingsverhaal zit sinds vorige week in fase 3; fase 4 staat gepland voor Q1 2027, na de december-support-dip.

Observability en failure-modes

Ship Rails passkeys vanaf dag één met instrumentatie. De failure-modes zijn subtiel en de foutmeldingen die browsers aan gebruikers laten zien zijn dat niet. Ik emit vier counters via Sentry en StatsD:

  • passkey.registration.success en passkey.registration.failure (met de exception-class als tag).
  • passkey.signin.success en passkey.signin.failure (zelfde tag-scheme).
  • passkey.signin.credential_not_found — een specifieke sub-klasse van failure. Een piek hier betekent meestal dat een gebruiker zijn device gereset heeft en zijn passkeys kwijt is, of dat je een code-wijziging hebt uitgerold die de credential-ID-lookup breekt.
  • passkey.signin.sign_count_mismatch — als dit ooit boven het baseline-niveau van iCloud-gesyncte nullen komt, kan een aanvaller een opgevangen credential opnieuw aan het afspelen zijn.

Bij de klant die ik nu ondersteun is de meest bruikbare doorlopende metric passkey.signin.duration_ms. Die zit rond de 800 ms end-to-end (grotendeels biometrische prompt-tijd), en elke afwijking boven de 2 seconden correleert met backend-latency-issues die anders in het algemene auth-verkeer zouden verdwijnen.

De opbrengst

Tien weken na dat eerste CISO-telefoontje had de klant passkeys live in productie. Zes weken daarna waren wachtwoord-reset-tickets met 70% gedaald. De SOC 2-auditor sloot de phishing-bestendige-MFA-finding. En in de laatste drie sales-calls waarin ik zat vroeg de security-reviewer “ondersteunen jullie passkeys?” en het antwoord was ja. Dát is wat Rails passkeys opleveren: betere beveiliging, minder support-load, en een concurrerend antwoord op een vraag die in 2026 in elk enterprise-inkoopproces gesteld wordt.

FAQ

Werken Rails passkeys in Safari op iOS en macOS?

Ja, en juist daar komen ze tot hun recht. Safari op iOS 16+ en macOS Ventura+ ondersteunt WebAuthn met iCloud Keychain-sync, wat betekent dat een passkey die op een iPhone geregistreerd is ook op een Mac en iPad werkt zonder opnieuw te registreren. Zorg specifiek in Safari dat je rp_id overeenkomt met een eTLD+1 die de browser vertrouwt — het associated-domains-bestand is niet vereist voor basis-passkey-flows, alleen voor cross-app / autofill-integratie.

Kan ik passkeys naast een wachtwoord als tweede factor gebruiken?

Ja. Zet user_verification: "required" in je create-options call en behandel een succesvolle passkey-verificatie alsof die aan je MFA-eis voldoet. Dat is precies fase 1 van het uitrolplan hierboven en het veiligste startpunt. webauthn-ruby maakt het niet uit of de passkey een eerste of tweede factor is — dat beleid leeft volledig in je controller.

Hoe laat ik gebruikers hun passkeys verwijderen of hernoemen?

Geef de gebruiker een settings-pagina waar zijn webauthn_credentials op staan en waar hij elke rij kan verwijderen of hernoemen. Het nickname-veld op de credential is wat hij ziet; sla daar iets leesbaars op (“iPhone 15”, “YubiKey NFC”) in plaats van de rauwe credential-ID. De rij verwijderen is voldoende — de browser en OS houden de privésleutel nog, maar jouw Rails-app zal weigeren die te authenticeren. Moedig gebruikers aan om hem ook uit hun password-manager-UI te verwijderen voor de netheid.

Wat als een gebruiker alle devices met zijn passkey kwijtraakt?

Je hebt een recovery-pad nodig dat geen passkey is. De drie patronen die ik uitgerold heb: (1) een gemailde magic link waarmee ze na klikken een nieuwe passkey mogen registreren, achter een rate-limit, (2) een set eenmalige recovery-codes die bij registratie getoond worden en door de gebruiker in een password manager bewaard worden, en (3) SSO-fallback via Google of Microsoft voor accounts die überhaupt via SSO uitgerold zijn. Wat je ook kiest, het recovery-pad is de zwakste schakel in je hele authenticatie-verhaal — en verdient dezelfde security-review als de passkey-flow zelf.

Passkeys aan het uitrollen in een Rails-app en wil je zeker weten dat de flow, de fallbacks en de recovery-paden overeind blijven onder echte gebruikers? TTB Software helpt teams om passwordless authenticatie end-to-end uit te rollen, van webauthn-ruby-integratie tot het SOC 2-gesprek. Negentien jaar Rails, en dit is de auth-primitive waar ik op gewacht heb.

#rails-passkeys #rails-webauthn #webauthn-ruby #rails-passwordless-authentication #passkeys-rails-8 #rails-fido2

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software