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

  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. 

Setup

  1. Add the webauthn gem into gemfile.

    gem 'webauthn'
    
  2. Perform bundle install.

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

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

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

    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.

      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.

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

  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.

      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.

    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.

      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.

  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.


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

You will get an error.