---
title: Adding WebAuthn to a Rails web application
url: https://calvin.my/posts/adding-webauthn-to-a-rails-web-application
published: 2024-07-24
updated: 2026-09-17
category: Development
tags:
- WebAuthn
- Rails
- Security
summary: This guide demonstrates adding WebAuthn to a Rails application using backend and browser libraries. It requires users to first authenticate through an existing method, then enroll authenticators. The implementation stores a unique WebAuthn user ID and credential data, uses challenge-response flows for enrollment and login, verifies signatures and counters, and updates stored credential state. Tests show support for Touch ID, mobile authenticators, and prevention of duplicate registrations on the same device.
---

# Adding WebAuthn to a Rails web application

This demo shows how to implement WebAuthn in a Rails web application.

The [webauthn-ruby](https://github.com/cedarcode/webauthn-ruby) gem is used in the backend and GitHub [webauthn-json](https://github.com/github/webauthn-json) is used in the frontend.

* * *

## Pre-requisites

1. To implement WebAuthn, you must have an existing authentication channel (E.g. username/password, OAuth).

2. Your user must be authenticated, and then enroll their devices with Webauthn.&nbsp;

## Setup

1. Add the webauthn gem into gemfile.

   ```ruby
   gem 'webauthn'
   ```

2. Perform bundle install.

3. Create an initializer for the webauthn gem.

   ```ruby
   WebAuthn.configure do |config|
     # This value needs to match `window.location.origin` evaluated by
     # the User Agent during registration and authentication ceremonies.
     config.origin = 'http://localhost:3000'

     # Relying Party name for display purposes
     config.rp_name = 'My App'
   end
   ```

4. Include the webauthn-json library via NPM or CDN. In my case, I am pointing to jspm.

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

## Database

1. A new column is needed in your 'users' table to store your webauth ID. This ID is unique for each of your users.

   ```ruby
   def change
       add_column :users, :webauthn_id, :string
   end
   ```

2. A new table is needed to store your credentials. Each record is tied to one enrollment.

   ```ruby
   def change
       create_table :credentials do |t|
         t.belongs_to :user, null: false, foreign_key: true
         t.string :webauthn_ref_id, null: false
         t.string :webauthn_public_key
         t.integer :webauthn_sign_count
         t.timestamps
       end
   end
   ```

3. Update models

   ```ruby
   class User < ApplicationRecord
     has_many :credentials
     # other codes
   end

   class Credential < ApplicationRecord
     belongs_to :user
   end
   ```

## How to Enroll?

The enrollment involves 2 steps - Initialization and validation.

1. Create a controller action to start enrollment.

   ```ruby
     def enroll
       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 { |c| c.webauthn_ref_id }
       )

       session[:creation_challenge] = options.challenge

       render json: options
     end
   ```

2. Add a button on your user profile page (or somewhere that can be accessible only after the user signs in)

3. This button requests your controller to retrieve the initial JSON data, then calls the webauthn API to start enrollment, and finally sends the result back to your backend.

   ```javascript
   import { Controller } from "@hotwired/stimulus"
   import {
       create,
       parseCreationOptionsFromJSON,
   } from "@github/webauthn-json/browser-ponyfill"

   export default class extends Controller {
       async enroll() {
           // Fetch the public key creation options from the server
           const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
           const request = fetch("/enroll_path", {
               method: "GET",
               headers: {
                   "Accept": "application/json",
                   "X-CSRF-Token": token,
               }
           })
           const json = await (await request).json();
           const options = parseCreationOptionsFromJSON({ publicKey: json });
           const response = await create(options);
           fetch('/validate_path', {
               method: 'POST',
               headers: {
                   'Content-Type': 'application/json',
                   'X-CSRF-Token': token,
               },
               body: JSON.stringify(response)
           }).then(response => {
               if (response.ok) {
                   window.location.reload();
               }
           })
       }
   }
   ```

4. The backend controller action should create a record in the 'credentials' table and store the public key.

   ```ruby
     def validate
       webauthn_credential = WebAuthn::Credential.from_create(params)

       begin
         webauthn_credential.verify(session[:creation_challenge])

         user.credentials.create!(
           webauthn_ref_id: webauthn_credential.id,
           webauthn_public_key: webauthn_credential.public_key,
           webauthn_sign_count: webauthn_credential.sign_count
         )
       rescue WebAuthn::Error => e
         head :bad_request and return
       end

       session.delete(:creation_challenge)

       head :ok
     end
   ```

## Test the Enrollment

1. I tested this on my Macbook, with Google Chrome browser.

2. Click the enrollment button and a list of options shows.

   ![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/4edb11db-a4e3-44ef-96c0-0573b0b545fd.png)

3. I selected the iCloud keychain. Then I verified myself via Touch ID.

   ![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/0e2a543e-227f-4f71-9641-bfc0886a2c48.png)

4. And the enrollment is done.

## How to log in with WebAuthn?

The login also involves 2 steps - Initialization and validation, similar to the enrollment process.

1. Create a controller action to start login.

   ```ruby
     def login
       user = User.find_by(email: params[:username])
       return head :not_found unless user

       options = WebAuthn::Credential.options_for_get(allow: user.credentials.map(&:webauthn_ref_id))
       session[:authentication_challenge] = options.challenge
       session[:username] = params[:username]

       render json: options
     end
   ```

2. Add username input and a button on your login screen.

3. The button sends your username to generate a challenge JSON. Then complete the challenge on your browser and send the result back.

   ```javascript
   import { Controller } from "@hotwired/stimulus"
   import {
       get,
       parseRequestOptionsFromJSON,
   } from "@github/webauthn-json/browser-ponyfill"

   export default class extends Controller {
       async login() {
           const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
           const username = this.usernameTarget.value
           fetch("/login", {
               method: "POST",
               headers: {
                   "Accept": "application/json",
                   "Content-Type": "application/json",
                   "X-CSRF-Token": token,
               },
               body: JSON.stringify({ username: username })
           }).then(async challenge => {
               if (!challenge.ok) {
                   // Show error
                   return
               }
               const json = await challenge.json();
               const options = parseRequestOptionsFromJSON({ publicKey: json });
               const webauthnResponse = await get(options);
               return fetch('/login_validate', {
                   method: 'POST',
                   headers: {
                       'Content-Type': 'application/json',
                       'X-CSRF-Token': token,
                   },
                   body: JSON.stringify(webauthnResponse)
               });
           }).then(response => {
               if (response.ok) {
                   window.location.href = '/some-where'
               } else {
                   // Show error
               }
           }).catch(() => {
               // Show error
           })
       }
   }
   ```

4. Finally, you should validate the login result and allow your user to pass.

   ```ruby
     def validate_login
       webauthn_credential = WebAuthn::Credential.from_get(params)
       user = User.find_by(email: session[:username])
       return head :bad_request unless user

       stored_credential = user.credentials.find_by(webauthn_ref_id: webauthn_credential.id)

       begin
         webauthn_credential.verify(
           session[:authentication_challenge],
           public_key: stored_credential.webauthn_public_key,
           sign_count: stored_credential.webauthn_sign_count
         )

         # Update the stored credential sign count with the value from `webauthn_credential.sign_count`
         stored_credential.update!(webauthn_sign_count: webauthn_credential.sign_count)

         session.delete(:authentication_challenge)
         session.delete(:username)

         # create new session to complete the login process

       rescue WebAuthn::SignCountVerificationError => e
         # Cryptographic verification of the authenticator data succeeded, but the signature counter was less then or equal
         # to the stored value. This can have several reasons and depending on your risk tolerance you can choose to fail or
         # pass authentication. For more information see https://www.w3.org/TR/webauthn/#sign-counter
         logger.error "WebAuthn error: #{e.message}"
         head :bad_request and return
       rescue WebAuthn::Error => e
         # Handle error
         logger.error "WebAuthn error: #{e.message}"
         head :bad_request and return
       end
       head :ok
     end
   ```

## Test the Login

1. I fill up my username and hit login.

2. The Touch ID shows up and I completed the verification.

   ![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/9fd36680-06c4-438c-9e0f-22deec62f191.png)

3. That's it. The login process is done.

* * *

## Bonus 1: Does this also work on mobile phones?

Yes, the same code above with a Samsung Galaxy Phone shows some options like the following.

![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/8bfbd302-a4df-4455-9a26-c44a804087dc.jpg)

* * *

## Bonus 2: What if I repetitively register on the same device?

You will get an error.&nbsp;

![](https://camy-pub.s3.ap-southeast-1.amazonaws.com/60e901f1-687b-40e1-a9f8-089613841588.png)
