---
title: Adding WebAuthn to a Rails web application (2026)
url: https://calvin.my/posts/adding-webauthn-to-a-rails-web-application-2026
published: 2026-09-28
updated: 2026-09-28
category: Development
tags:
- Rails
- Security
- WebAuthn
- Importmap
summary: This Rails 8.1 WebAuthn update replaces a deprecated browser library with native credential JSON methods and a compatibility fallback. It fixes replay risks from cookie-based session challenges by storing short-lived, single-use challenges in the database while keeping only a token in the session. The guide also requires user verification, hides unknown usernames with decoy options, updates origin configuration, and recommends replay, browser-helper, and rate-limit testing.
---

# Adding WebAuthn to a Rails web application (2026)

## 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](https://calvin.my/posts/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](https://github.com/github/webauthn-json))

**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.

```bash
% bundle add webauthn
```

2. Create `config/initializers/webauthn.rb`.

```ruby
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.

```ruby
class AddWebauthnIdToUsers < ActiveRecord::Migration[8.1]
  def change
    add_column :users, :webauthn_id, :string
  end
end
```

2. Create the credentials table.

```ruby
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
```

3. Create the challenges table. This one is new.

```ruby
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
```

4. Add the models.

```ruby
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`.

```javascript
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.

2. Pin it in `config/importmap.rb`.

```ruby
# 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.

```ruby
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
```

2. Call it from a Stimulus controller.

```javascript
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()
    }
}
```

3. Consume the challenge and verify.

```ruby
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.

```ruby
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.

2. Call it from a Stimulus controller.

```javascript
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"
    }
}
```

3. Consume the challenge and verify.

```ruby
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.

```ruby
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`.

2. 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:

```javascript
Object.defineProperty(PublicKeyCredential, "parseCreationOptionsFromJSON", { value: undefined })
Object.defineProperty(PublicKeyCredential, "parseRequestOptionsFromJSON", { value: undefined })
Object.defineProperty(PublicKeyCredential.prototype, "toJSON", { value: undefined })
```

```markup
native: registration + authentication verified by webauthn-ruby (sign_count 2)
fallback: registration + authentication verified by webauthn-ruby (sign_count 2)
```

3. 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.
