This demo shows how to implement WebAuthn in a Rails web application.
The webauthn-ruby gem is used in the backend and GitHub webauthn-json is used in the frontend.
Pre-requisites
-
To implement WebAuthn, you must have an existing authentication channel (E.g. username/password, OAuth).
-
Your user must be authenticated, and then enroll their devices with Webauthn.
Setup
-
Add the webauthn gem into gemfile.
gem 'webauthn' -
Perform bundle install.
-
Create an initializer for the webauthn gem.
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 -
Include the webauthn-json library via NPM or CDN. In my case, I am pointing to jspm.
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
-
A new column is needed in your 'users' table to store your webauth ID. This ID is unique for each of your users.
def change add_column :users, :webauthn_id, :string end -
A new table is needed to store your credentials. Each record is tied to one enrollment.
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 -
Update models
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.
-
Create a controller action to start enrollment.
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 -
Add a button on your user profile page (or somewhere that can be accessible only after the user signs in)
-
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.
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(); } }) } } -
The backend controller action should create a record in the 'credentials' table and store the public key.
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
-
I tested this on my Macbook, with Google Chrome browser.
-
Click the enrollment button and a list of options shows.

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

-
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.
-
Create a controller action to start login.
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 -
Add username input and a button on your login screen.
-
The button sends your username to generate a challenge JSON. Then complete the challenge on your browser and send the result back.
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 }) } } -
Finally, you should validate the login result and allow your user to pass.
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
-
I fill up my username and hit login.
-
The Touch ID shows up and I completed the verification.

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

Bonus 2: What if I repetitively register on the same device?
You will get an error.
