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:
- 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
- Add the gem and install it.
% bundle add webauthn
- 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
- Add
webauthn_idto users.
class AddWebauthnIdToUsers < ActiveRecord::Migration[8.1]
def change
add_column :users, :webauthn_id, :string
end
end
- 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
- 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
- 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.
- 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.
- 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.
- 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
- 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()
}
}
- 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
- 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.
- 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"
}
}
- 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
- 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.
- 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)
- 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, andconsumedeletes used ones, so the table stays small without a cleanup job. - Rate limit both steps. Rails 8's built-in
rate_limitis enough. Without it, the decoy path makes username probing cheap.