Skip to content

MVP: Add WebAuthn passkey authentication support#1108

Draft
heyvaleria wants to merge 5 commits intomainfrom
vg_add_passkey
Draft

MVP: Add WebAuthn passkey authentication support#1108
heyvaleria wants to merge 5 commits intomainfrom
vg_add_passkey

Conversation

@heyvaleria
Copy link
Copy Markdown
Contributor

Before this PR, Clearance's only authentication path was email and password.
There was no built-in support for WebAuthn passkeys, so apps that wanted passkey sign-in had to build the entire ceremony layer themselves, managing credential options, challenge storage, assertion verification, and session sign-in completely outside of Clearance.

The goal of this code change was to add passkey support as an opt-in feature: run a generator to prepare the database and User model, configure the webauthn gem for the host app that uses clearance, and get a working JSON backend for both the registration and authentication ceremonies, integrated with Clearance's existing sign_in guard stack.

Clearance::Passkey is a new ActiveRecord model that belongs to a user and stores the credential's external_id (the ID issued by the authenticator), public_key, sign_count, and a human-readable label. external_id is validated for uniqueness so the same credential can't be registered twice.
The clearance:passkeys generator sets up everything the host app needs: it creates an add_webauthn_id_to_users migration (skipped if the column already exists) and a create_passkeys migration (skipped if the table already exists), injects has_many :passkeys into the User model, and prints a README with instructions for configuring the webauthn gem.

PasskeysController handles the registration ceremony.
GET /passkeys/new returns WebAuthn credential creation options as JSON and stores the challenge in the session; the user's webauthn_id is generated lazily at this point if not already set.
POST /passkeys verifies the credential response and persists the new passkey against the current user.
Registration requires the user to be signed in.

PasskeyAuthenticationsController handles the authentication ceremony.
GET /passkey_authentication/new returns assertion options as JSON.
POST /passkey_authentication verifies the assertion, updates the stored sign count, and delegates to Clearance's sign_in so the full guard stack still runs.
On success it responds with JSON containing a redirect_to key pointing to Clearance.configuration.redirect_url.

Both controllers render JSON, since the WebAuthn API is driven by browser-side JavaScript.
Routes for both are added inside the existing routes_enabled? block.

In a flexible thoughbot approach, I decided that the WebAuthn configuration (origin, rp_name) is deliberately left to the host app via WebAuthn.configure rather than threading it through Clearance.configuration — the webauthn gem already provides the right home for those settings.

Setup instructions

1) Install

bundle add clearance
rails generate clearance:install   # skip if already using Clearance                                                                
rails generate clearance:passkeys
rails db:migrate

By running bundle add clearance, webauthn ~> 3.0 comes as a dependency.

2) Configure WebAuthn

Create config/initializers/webauthn.rb

  WebAuthn.configure do |config|                                                                                                      
    config.origin = "https://yourapp.example.com"  # "http://localhost:3000" in dev
    config.rp_name = "Your App Name"                                                                                                  
  end

3) Endpoints

Screenshot 2026-04-23 at 13 22 17

4) Registration flow (user must be signed in)

  1. Fetch creation options from Clearance
  const options = await fetch("/passkeys/new").then(r => r.json()) 
  1. Prompt the browser to create a credential
  const credential = await navigator.credentials.create({ publicKey: options })
  1. POST the result back, including a human-readable label
  await fetch("/passkeys", {
    method: "POST",                                                                                                                   
    headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
    body: JSON.stringify({ ...credential, label: "My MacBook" })                                                                      
  })

5) Authentication flow

  1. Fetch assertion options
  const options = await fetch("/passkey_authentication/new").then(r => r.json())
  1. Prompt the browser to sign with a passkey
  const credential = await navigator.credentials.get({ publicKey: options })             
  1. POST the result — on success, response body has { redirect_to: "/dashboard" }
  const result = await fetch("/passkey_authentication", {
    method: "POST",                                                                                                                   
    headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken },
    body: JSON.stringify(credential)                                                                                                  
  }).then(r => r.json())                                                                                                              
  
  window.location = result.redirect_to       

The WebAuthn browser API expects specific object shapes — for a production app you'd typically reach for a library like @github/webauthn-json to handle the Base64url encoding/decoding between the JSON and the native credential objects.

Introduces opt-in passkey (WebAuthn) support to Clearance via a new
generator and model.

Add the `webauthn ~> 3.0` gem dependency.

Add `Clearance::Passkey` (lib/clearance/passkey.rb), an ActiveRecord
model with:
- `belongs_to :user, class_name: "::User", optional: false`
- presence validations on `label`, `external_id`, and `public_key`
- uniqueness validation on `external_id`

Add the `clearance:passkeys` generator
(lib/generators/clearance/passkeys/) which produces two migrations:
- `add_webauthn_id_to_users` — adds a unique `webauthn_id:string`
  column to the users table (skipped if column already exists)
- `create_passkeys` — creates the passkeys table with `user_id`,
  `label`, `external_id`, `public_key`, `sign_count` (not null,
  default 0), timestamps, and a unique index on `external_id`
  (skipped if table already exists)

Wire up the dummy app with the corresponding migrations, an updated
schema, and a `Passkey < Clearance::Passkey` model.

Add a `:passkey` FactoryBot factory and specs covering the generator
and the model (belongs_to, validations, db columns/indexes, and
uniqueness behaviour including after destroy).
Before this change, the clearance:passkeys generator set up the
necessary database structure — a passkeys table and a webauthn_id
column on users — but there was no controller layer. There was no
way for a user to register a passkey or sign in with one.

The goal was to wire up both WebAuthn ceremonies using the webauthn
gem's credential API, following the same patterns as Clearance's
existing session and password controllers.

PasskeysController handles registration: GET /passkeys/new generates
WebAuthn credential creation options and stores the challenge in the
session; POST /passkeys verifies the browser's response and persists
the new passkey against the signed-in user. The webauthn_id on the
user is set lazily on first registration. PasskeyAuthenticationsController
handles sign-in: GET /passkey_authentication/new returns assertion
options, and POST /passkey_authentication verifies the credential,
updates the stored sign count, and delegates to Clearance's sign_in
so the full guard stack still runs. Both controllers render JSON,
since the WebAuthn API is JavaScript-driven. Routes for both are
added inside the existing routes_enabled? block.

The generator was also updated to inject has_many :passkeys into the
host app's User model, and to print a README after running with
instructions for configuring the webauthn gem. That configuration
(origin and rp_name) is deliberately left to the host app via
WebAuthn.configure rather than routing it through
Clearance.configuration — the webauthn gem already provides a good
home for those settings and wrapping them would add abstraction
without benefit.
@heyvaleria heyvaleria changed the title Add WebAuthn passkey authentication support MVP: Add WebAuthn passkey authentication support Apr 23, 2026
inject_into_class inserts at the top of the class body, which placed
has_many :passkeys before include Clearance::User. Switched to
inject_into_file with after: "include Clearance::User\n" so the
association is always declared after the module inclusion. Also
tightened the spec to assert ordering rather than just presence.
Replace deprecated config.origin= with config.allowed_origins=, and
expand the frontend setup instructions to show the importmap install
path and working JS snippets for both the registration and
authentication flows.
Add the missing instruction to pin the app's passkeys JS file in
config/importmap.rb, without which the module specifier cannot be
resolved and the WebAuthn flows silently fail.
@FerPerales
Copy link
Copy Markdown
Member

@heyvaleria looks good! I'll do some testing during upcoming investment time. Shall we release this as a new beta version so you can add more features to make it production ready?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants