Overview

This article updates the 2024 WebAuthn guide for Rails. Two things in it no longer hold: the front-end library it used is deprecated, and keeping the challenge in the session is replayable when the session is a cookie.

Related article:

  1. Adding WebAuthn to a Rails web application (Jul 2024)

Versions as of writing: Rails 8.1, Ruby 4.0, webauthn-ruby 3.4.3, Importmap, Stimulus.


What changed since 2024

2024 guide 2026
@github/webauthn-json from jspm Native PublicKeyCredential.parse*OptionsFromJSON and toJSON(), plus a small local fallback
Challenge kept in session[...] Challenge kept in a database row; the session holds only a random token
config.origin config.allowed_origins
No user verification requirement user_verification: "required" on both ceremonies
Unknown username returns 404 Unknown username gets decoy options, so accounts cannot be enumerated

webauthn-json is deprecated. The repository is archived, and its README says "As of March 2025, stable versions of all major browsers now support" the native methods. (Reference)

The session challenge is replayable. Rails' default session store is an encrypted cookie. session.delete(:authentication_challenge) only changes the next cookie the server sends; the earlier cookie still carries the challenge and stays valid. Anyone who captures that cookie and the signed assertion from one login can send both again.


A) Setup

  1. Add the gem and install it.
% bundle add webauthn
  1. Create config/initializers/webauthn.rb.
WebAuthn.configure do |config|
  # Previous: config.origin = "http://localhost:3000"
  config.allowed_origins = [ "http://localhost:3000" ]
  config.rp_name = "My App"
end

NOTE: WebAuthn.origin still exists but prints a deprecation warning and will be removed. Use your real https:// origin in production.


B) Database

  1. Add webauthn_id to users.
class AddWebauthnIdToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :webauthn_id, :string
  end
end
  1. Create the credentials table.
class CreateCredentials < ActiveRecord::Migration[8.1]
  def change
    create_table :credentials do |t|
      t.references :user, null: false, foreign_key: true
      t.string :webauthn_id, null: false
      t.text :webauthn_public_key, null: false
      t.integer :webauthn_sign_count, null: false, default: 0
      t.timestamps
    end
    add_index :credentials, [ :user_id, :webauthn_id ], unique: true
  end
end
  1. Create the challenges table. This one is new.
class CreateWebauthnChallenges < ActiveRecord::Migration[8.1]
  def change
    create_table :webauthn_challenges do |t|
      t.references :user, null: false, foreign_key: true
      t.string :purpose, null: false
      t.string :challenge, null: false
      t.string :token, null: false
      t.timestamps
    end
    add_index :webauthn_challenges, :token, unique: true
  end
end
  1. Add the models.
class User < ApplicationRecord
  has_many :credentials, dependent: :destroy
  has_many :webauthn_challenges, dependent: :delete_all
end

class Credential < ApplicationRecord
  belongs_to :user
end

class WebauthnChallenge < ApplicationRecord
  # Well past the 120-second ceremony timeout webauthn-ruby puts in the options
  TTL = 5.minutes

  belongs_to :user

  enum :purpose, { authentication: "authentication", registration: "registration" }, validate: true

  has_secure_token :token

  validates :challenge, presence: true

  scope :live, -> { where(created_at: TTL.ago..) }
  scope :expired, -> { where(created_at: ...TTL.ago) }

  def self.issue!(user:, purpose:, challenge:)
    expired.delete_all
    create!(user: user, purpose: purpose, challenge: challenge)
  end

  # Only the request whose DELETE removed the row gets the challenge,
  # so two requests presenting the same token cannot both finish
  def self.consume(token, purpose:)
    return if token.blank?

    pending = live.find_by(token: token.to_s, purpose: purpose)
    pending if pending && where(id: pending.id).delete_all == 1
  end
end

The delete_all == 1 check is what makes a challenge single-use. A plain find_by then destroy would let two concurrent requests read the same row before either deletes it.


C) The browser helper

This replaces both @github/webauthn-json pins.

  1. Create app/javascript/webauthn_json.js.
function toBuffer(base64url) {
    const base64 = base64url.replace(/-/g, "+").replace(/_/g, "/")
    const padded = base64 + "=".repeat((4 - base64.length % 4) % 4)
    return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)).buffer
}

function toBase64url(buffer) {
    let binary = ""
    for (const byte of new Uint8Array(buffer)) binary += String.fromCharCode(byte)
    return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "")
}

function withBufferIds(descriptors) {
    return descriptors?.map((descriptor) => ({ ...descriptor, id: toBuffer(descriptor.id) }))
}

function creationOptions(json) {
    if (PublicKeyCredential.parseCreationOptionsFromJSON) {
        return PublicKeyCredential.parseCreationOptionsFromJSON(json)
    }
    return {
        ...json,
        challenge: toBuffer(json.challenge),
        user: { ...json.user, id: toBuffer(json.user.id) },
        excludeCredentials: withBufferIds(json.excludeCredentials),
    }
}

function requestOptions(json) {
    if (PublicKeyCredential.parseRequestOptionsFromJSON) {
        return PublicKeyCredential.parseRequestOptionsFromJSON(json)
    }
    return {
        ...json,
        challenge: toBuffer(json.challenge),
        allowCredentials: withBufferIds(json.allowCredentials),
    }
}

function credentialToJSON(credential) {
    if (typeof credential.toJSON === "function") return credential.toJSON()

    const { response } = credential
    const json = {
        id: credential.id,
        rawId: toBase64url(credential.rawId),
        type: credential.type,
        authenticatorAttachment: credential.authenticatorAttachment,
        clientExtensionResults: credential.getClientExtensionResults(),
        response: { clientDataJSON: toBase64url(response.clientDataJSON) },
    }
    if (response.attestationObject) {
        json.response.attestationObject = toBase64url(response.attestationObject)
        json.response.transports = response.getTransports?.() ?? []
    } else {
        json.response.authenticatorData = toBase64url(response.authenticatorData)
        json.response.signature = toBase64url(response.signature)
        json.response.userHandle = response.userHandle && toBase64url(response.userHandle)
    }
    return json
}

export async function createCredential(json) {
    const credential = await navigator.credentials.create({ publicKey: creationOptions(json) })
    return credentialToJSON(credential)
}

export async function getCredential(json) {
    const credential = await navigator.credentials.get({ publicKey: requestOptions(json) })
    return credentialToJSON(credential)
}

The fallback covers browser releases from before the native methods. Omit it if you only support current browsers; the module shrinks to the two exported functions calling the native methods.

  1. Pin it in config/importmap.rb.
# Previous (To be removed)
pin "@github/webauthn-json", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.js"
pin "@github/webauthn-json/browser-ponyfill", to: "https://ga.jspm.io/npm:@github/webauthn-json@2.1.1/dist/esm/webauthn-json.browser-ponyfill.js"

# New
pin "webauthn_json", to: "webauthn_json.js", preload: false

NOTE: The options go in as the server's JSON directly. The old { publicKey: json } wrapper was a webauthn-json convention.


D) How to enroll

The user must already be signed in (password or OAuth) before enrolling a device.

  1. Issue the creation options and store the challenge server-side.
def enroll
  user = current_user
  user.update!(webauthn_id: WebAuthn.generate_user_id) unless user.webauthn_id

  options = WebAuthn::Credential.options_for_create(
    user: { id: user.webauthn_id, name: user.email, display_name: user.name },
    exclude: user.credentials.map(&:webauthn_id),
    authenticator_selection: { user_verification: "required" }
  )

  # Previous: session[:creation_challenge] = options.challenge
  pending = WebauthnChallenge.issue!(user: user, purpose: :registration, challenge: options.challenge)
  session[:webauthn_challenge] = pending.token

  render json: options
end
  1. Call it from a Stimulus controller.
import { Controller } from "@hotwired/stimulus"
import { createCredential } from "webauthn_json"

export default class extends Controller {
    async enroll() {
        const token = document.querySelector('meta[name="csrf-token"]').getAttribute("content")
        const request = await fetch("/profile/enroll_webauthn", {
            headers: { "Accept": "application/json", "X-CSRF-Token": token },
        })
        const credential = await createCredential(await request.json())
        const response = await fetch("/profile/validate_enroll_webauthn", {
            method: "POST",
            headers: { "Content-Type": "application/json", "X-CSRF-Token": token },
            body: JSON.stringify(credential),
        })
        if (response.ok) window.location.reload()
    }
}
  1. Consume the challenge and verify.
def validate_enroll
  # Consume it whether or not this attempt succeeds
  pending = WebauthnChallenge.consume(session.delete(:webauthn_challenge), purpose: :registration)
  return head :bad_request unless pending && pending.user_id == current_user.id

  webauthn_credential = WebAuthn::Credential.from_create(params)
  webauthn_credential.verify(pending.challenge, user_verification: true)

  current_user.credentials.create!(
    webauthn_id: webauthn_credential.id,
    webauthn_public_key: webauthn_credential.public_key,
    webauthn_sign_count: webauthn_credential.sign_count
  )
  head :ok
rescue WebAuthn::Error => e
  logger.error "WebAuthn error: #{e.message}"
  head :bad_request
end

NOTE: The 2024 version called verify(session[:creation_challenge]) without checking it was present. Reject a request with no challenge before verifying anything.


E) How to log in

  1. Issue the request options. Return decoy options for an unknown username, so the response does not reveal which accounts exist.
rate_limit to: 6, within: 5.minutes, only: [ :login, :validate_login ]

def login
  user = User.find_by(email: params[:username].to_s.downcase)

  if user
    options = WebAuthn::Credential.options_for_get(
      allow: user.credentials.map(&:webauthn_id),
      user_verification: "required"
    )
    pending = WebauthnChallenge.issue!(user: user, purpose: :authentication, challenge: options.challenge)
    session[:webauthn_challenge] = pending.token
  else
    # Same shape as a real response, and stable per username; nothing is stored
    options = WebAuthn::Credential.options_for_get(
      allow: [ decoy_credential_id(params[:username].to_s.downcase) ],
      user_verification: "required"
    )
  end

  render json: options
end

private

def decoy_credential_id(username)
  digest = OpenSSL::HMAC.digest("SHA256", Rails.application.secret_key_base, username)
  Base64.urlsafe_encode64(digest, padding: false)
end

The username no longer goes into the session: the challenge row already knows its user.

  1. Call it from a Stimulus controller.
import { Controller } from "@hotwired/stimulus"
import { getCredential } from "webauthn_json"

export default class extends Controller {
    static targets = [ "username" ]

    async login() {
        const token = document.querySelector('meta[name="csrf-token"]').getAttribute("content")
        const challenge = await fetch("/login", {
            method: "POST",
            headers: { "Accept": "application/json", "Content-Type": "application/json", "X-CSRF-Token": token },
            body: JSON.stringify({ username: this.usernameTarget.value }),
        })
        if (!challenge.ok) return

        const credential = await getCredential(await challenge.json())
        const response = await fetch("/login_validate", {
            method: "POST",
            headers: { "Content-Type": "application/json", "X-CSRF-Token": token },
            body: JSON.stringify(credential),
        })
        if (response.ok) window.location.href = "/some-where"
    }
}
  1. Consume the challenge and verify.
def validate_login
  pending = WebauthnChallenge.consume(session.delete(:webauthn_challenge), purpose: :authentication)
  return head :bad_request unless pending

  webauthn_credential = WebAuthn::Credential.from_get(params)
  stored_credential = pending.user.credentials.find_by(webauthn_id: webauthn_credential.id)
  return head :bad_request unless stored_credential

  webauthn_credential.verify(
    pending.challenge,
    public_key: stored_credential.webauthn_public_key,
    sign_count: stored_credential.webauthn_sign_count,
    user_verification: true
  )
  stored_credential.update!(webauthn_sign_count: webauthn_credential.sign_count)

  reset_session
  # create new session to complete the login process
  head :ok
rescue WebAuthn::Error => e
  logger.error "WebAuthn error: #{e.message}"
  head :bad_request
end

WebAuthn::SignCountVerificationError is a subclass of WebAuthn::Error, so the separate rescue from 2024 is not needed.


F) Verify

  1. Lock the replay fix in with an integration test. It captures the cookie from step 1, logs in, then sends the old cookie and the same assertion again.
test "replaying the step-1 cookie with the same assertion does not log in again" do
  post login_path, params: { username: @user.email }
  captured_cookie = cookies["_myapp_session"]

  # A synced passkey reports a sign count of 0 every time
  mock_cred = mock("credential")
  mock_cred.stubs(:id).returns(@credential.webauthn_id)
  mock_cred.stubs(:sign_count).returns(0)
  mock_cred.stubs(:verify).returns(true)
  WebAuthn::Credential.stubs(:from_get).returns(mock_cred)
  assertion = { id: @credential.webauthn_id, rawId: @credential.webauthn_id, type: "public-key" }

  post login_validate_path, params: assertion
  assert_response :success

  cookies["_myapp_session"] = captured_cookie
  post login_validate_path, params: assertion
  assert_response :bad_request
end

With the 2024 code the replayed request also returns 200. With the challenge table it returns 400.

  1. Check the browser helper against a real authenticator. Controller tests stub the ceremony, so they cannot catch a wrong byte encoding. Chrome's DevTools protocol has a virtual authenticator; I drove it with Playwright, ran enrollment and login through webauthn_json.js, and verified both results with webauthn-ruby. Run it twice, the second time with the native methods removed, to cover the fallback:
Object.defineProperty(PublicKeyCredential, "parseCreationOptionsFromJSON", { value: undefined })
Object.defineProperty(PublicKeyCredential, "parseRequestOptionsFromJSON", { value: undefined })
Object.defineProperty(PublicKeyCredential.prototype, "toJSON", { value: undefined })
native: registration + authentication verified by webauthn-ruby (sign_count 2)
fallback: registration + authentication verified by webauthn-ruby (sign_count 2)
  1. Enroll and log in by hand.

G) Things to take note of

  • Keep the challenge TTL above the ceremony timeout. webauthn-ruby puts "timeout": 120000 (120 seconds) in the options. A TTL shorter than that fails slow but legitimate logins.
  • Prune old rows. issue! deletes expired challenges on every issue, and consume deletes used ones, so the table stays small without a cleanup job.
  • Rate limit both steps. Rails 8's built-in rate_limit is enough. Without it, the decoy path makes username probing cheap.