Passkeys in Rails: The end of passwords?

Passkeys in Rails: The end of passwords?

Discover the future of online security with passkeys, by learning to code your own solution in Rails using webauthn-ruby or devise-passkeys for enhanced digital protection.
Tobias Heuts
Tobias Heuts
January 24, 2024
Web

Table of Contents

👋 Introduction

Before we dive into the realm of “Passkeys,” let’s take a moment to reflect on the challenges posed by traditional “Passwords.” We’ve all struggled with the inconvenience of managing multiple passwords, each service imposing its unique set of rules.

At the time of writing:

  • Facebook requires a minimum of 6 characters.
  • Google requires a minimum of 8 characters.
  • GitHub requires at least 15 characters OR at least 8 characters including a number and a lowercase letter.

On the other hand, using the same password for all services is a very bad practice. If one leaks, all your services are exposed at once, or setting a different password for each service becomes difficult to manage and remember.

While password managers offer a solution, they also introduce their security concerns by relying on a vulnerable master password. In summary, passwords are far from ideal due to their poor user experience and inherent security shortcomings. Enter the discussion about why “Passkeys” might just be the evolution we’ve been waiting for.

🔐 Passkeys

Passkeys revolutionize authentication for more secure and user-friendly methods. Instead of asking for something the user knows, passkeys ask for something the user possesses:

  • Biometrics: Unique physical characteristics, such as fingerprints or facial recognition.
  • Hardware Tokens: Physical devices like YubiKeys that generate one-time codes.
  • Mobile Device: Scanning a QR code using a mobile device, linking it to the user’s hardware.

Here is how a passkey dialog would look like in Safari on GitHub:

  • Once a user wants to sign up, notice the variety of methods for creating a passkey.
Sign-up dialog
Sign-up dialog
  • Once the user wants to sign in, a similar passkey dialog will show for authentication.
Sign-in dialog
Sign-in dialog

Observing Safari, the appearance may differ across browsers, but the underlying reason for this password-less authentication lies in the implementation of WebAuthn.

🧪 Do you want to try out the WebAuthn API yourself? Click here!

💡 Not sure if your current browser supports the WebAuthn API? Click here!

🌐 WebAuthn

WebAuthn, short for Web Authentication, is a FIDO specification that provides a secure and convenient way to authenticate users to web applications using Public Key Cryptography. It is designed to replace the traditional password-based authentication with a more robust and phishing-resistant method. But how does it work in the background?

Sign-Up

Let’s take the sign-up flow below as an example to highlight the workings of WebAuthn:

Sign-up flow with passkeys
Sign-up flow with passkeys
  1. User Input: The user provides their email or username on the browser.
  2. Challenge Creation: The server generates a unique challenge for security.
  3. User Authentication: The user authenticates using biometrics or a YubiKey.
  4. Key Pair Generation and Storage:
    • The client creates the credential, which consists of a private key and a public key
      • The private key is used to sign the challenge and is stored on the client. Depending on the chosen method it is stored on a system-level password manager (ex: iCloud Keychain) or on the device itself (ex: YubiKey).
      • The public key is sent to the server with the credential ID and signed challenge.
  5. WebAuthn Credential Validation:
    • The server validates the credential, based on the public key and signed challenge.
    • The server stores the public key and credential ID for future authentication.
  6. Authentication Completion: With successful validation, the user is now authenticated.

💡 A challenge is a generated string from the server to prevent replay attacks.

Sign-In

To complete the flow, let’s also have a look at the sign-in flow:

Sign-in flow with passkeys
Sign-in flow with passkeys
  1. User Input: The user provides their email or username on the browser.
  2. Challenge Creation: The server generates a unique challenge for security.
  3. User Authentication: The user authenticates using biometrics or a YubiKey.
  4. Sign Challenge: Retrieve the previously stored private key to sign the challenge. The private key is the same one created during sign-up and was stored on a system-level password manager (ex: iCloud Keychain) or a device (ex: YubiKey).
  5. WebAuthn Credential Validation:
    • The server retrieves the credential by using the credential ID.
    • The server validates the credential, based on the public key and signed challenge.
  6. Authentication Completion: With successful validation, the user is now authenticated.

🥊 Comparison

Now, let’s explore the integration of passkey authentication into our Rails application, within the context of Devise, a popular Ruby on Rails authentication solution. In this comparison, we evaluate two key libraries: webauthn-ruby and devise-passkeys, each offering distinct advantages and considerations:

💡 These libraries provide server-side (backend) implementation, for client-side (frontend) webauthn-json is the suggested library.

🛠️ Setup

🅰️ webauthn-ruby

The library provides freedom to the user to put passkey authentication flows wherever they need. Use the library to create challenges and authorize passkey calls in the controller.

💡 By storing the challenge in the session, the server can later verify that the response from the client matches the expected challenge (as specified in step 2).

1: Create a challenge and send it to the user.

# Sign-Up
options = WebAuthn::Credential.options_for_create(user: { id: user.webauthn_id, email: user.email })

session[:challenge] = options.challenge

render json: options
# Sign-In
options = WebAuthn::Credential.options_for_get(allow: user.credentials.map { |c| c.webauthn_id })

session[:challenge] = options.challenge

render json: options

2: Validate the credential, which was created by the client with webauthn-json

# Sign-Up
webauthn_credential = WebAuthn::Credential.from_create(params)

webauthn_credential.verify(session[:challenge])

user.credentials.create!(
  webauthn_id: webauthn_credential.id,
  public_key: webauthn_credential.public_key,
  sign_count: webauthn_credential.sign_count
)

render json: { status: :ok }
# Sign-In
webauthn_credential = WebAuthn::Credential.from_get(params)

credential = user.credentials.find_by(webauthn_id: webauthn_credential.id)

webauthn_credential.verify(
  session[:challenge],
  public_key: credential.public_key,
  sign_count: credential.sign_count
)

render json: { status: :ok }

🅱️ devise-passkeys

This only requires the authenticating model to be a devise model and uses passkey_authenticatable.

class User < ApplicationRecord
  devise :passkey_authenticatable, ...
end

Predefined functions, called concern by the library, are provided to be used in respective controllers.

class RegistrationsController < Devise::RegistrationsController
  include Devise::Passkeys::Controllers::RegistrationsControllerConcern
end

class SessionsController < Devise::SessionsController
  include Devise::Passkeys::Controllers::SessionsControllerConcern
end

✨ Provided Features

🅰️ webauthn-ruby

The library provides an API for any passkey authentication flow.

  1. Creating a challenge:
    • Sign-Up: WebAuthn::Credential.options_for_create(options)
    • Sign-In: WebAuthn::Credential.options_for_get(options)
  2. Process a credential:
    • Sign-Up: WebAuthn::Credential.from_create(params)
    • Sign-In: WebAuthn::Credential.from_get(params)
  3. Validate a credential:
    • Sign-Up: webauthn_credential.verify(challenge)
    • Sign-In: webauthn_credential.verify(challenge, public_key, sign_count)

💡 The API is not framework-specified and can be used alongside or without Devise.

🅱️ devise-passkeys

The library provides the necessary endpoints for a Devise passkey authentication flow.

This includes JSON support for the flows:

  1. Passkey registration and sign-in.
  2. Passkey challenge.
  3. Assertion for the data passed from the UI.
devise_scope :user do
  post 'sign_up/new_challenge', to: 'users/registrations#new_challenge', as: :new_user_registration_challenge
  post 'sign_in/new_challenge', to: 'users/sessions#new_challenge', as: :new_user_session_challenge
end

With these endpoints, it is possible to implement a password-less register and login flow out of the box. An additional feature included is Passkey management endpoints. The endpoints allow the user to view, add, and delete their passkeys.

# routes.rb with Passkey management endpoints
namespace :users do
  resources :passkeys, only: [:index, :create, :destroy] do
    collection do
      post :new_create_challenge
    end

    member do
      post :new_destroy_challenge
    end
  end
end
# Example of a form for adding a new passkey to a user
form_with(
  scope: :passkey,
  url: users_passkeys_url,
  method: :post,
  id: :"add-passkey-form",
  data: {
    "reauthentication_challenge_url": new_user_reauthentication_challenge_url,
    "reauthentication_token_url": user_reauthentication_url,
    "reauthentication_token_field_name": "passkey[reauthentication_token]",
    "challenge_url": new_create_challenge_users_passkeys_url,
    "credential_field_name": "passkey[credential]"
  }
)

Example codes for each of the library are available on devise-passkeys-template and webauthn-rails-demo-app.

🔍 What is under the hood?

🅰️ webauthn-ruby

webauthn-ruby is a library that performs passkey authentication and all cryptographic-related checks. The library relies on W3C’s specification for a WebAuthn Relying Party. The library includes mostly encryption-based libraries such as SSL and CBOR.

🅱️ devise-passkeys

The key anatomy of this library is the included functions called concerns. These concerns are a light wrapper for Devise authentication which utilizes the external library: warden-webauthn.

# devise-passkeys/../sessions_controller_concern.rb using warden-webauthn/../authentication_initiation_helpers.rb
included do
  include Warden::WebAuthn::AuthenticationInitiationHelpers
  include Warden::WebAuthn::RackHelpers
end

def new_challenge
  options_for_authentication = generate_authentication_options(relying_party: relying_party)
  store_challenge_in_session(options_for_authentication: options_for_authentication)
  render json: options_for_authentication
end

The devise-passkeys gem provides the model passkey_authenticatable, allowing Devise to authenticate the user’s model with the Passkey logic from warden-webauth. However, warden-webauthn is also a wrapper for webauthn-ruby. In the bigger picture, devise-passkeys is an indirect wrapper for the other library that it is being compared.

🛑 Limitation

🅰️ webauthn-ruby

This library requires a high learning curve. There is no one clear way to use this library as it provides a low level of passkey integration. However, there are good usage examples such as the library’s official example, Mastodon’s, and this talk. The tradeoff is the adaptability of the library in varieties of use cases.

🅱️ devise-passkeys

The library is intentionally designed to be non-configurable but also a lightweight library that is easy to use to implement a Passkey feature on a Devise application.

One thing to look out for when deciding to use this library is the model passkey_authenticatable. The core feature of the library heavily relies on this model. Having the authenticating Devise model be a passkey_authenticatable means that it will conflict with Devise’s database_authenticatable and the model cannot be both at the same time.

Another limitation has to do with the library’s configuration. It is possible to provide additional logic to the authentication flow by overriding functions provided by the concerns. However, it will require a thorough understanding of the library’s source code. The in-progress state of the document does not make this process easier.

🔥 Final Verdict

devise-passkeys excels in seamlessly integrating password-less authentication, prioritizing simplicity. While it can be adapted for alternative use cases, the resulting code may become cumbersome. Therefore, it is advisable to employ devise-passkey specifically when password-less authentication is a confirmed requirement, and there are no competing flows taking precedence. However, for scenarios that require Two Factor Authentication (2FA) or a customized passkey flow, webauthn-ruby is a better choice as it is fully customizable.

💭 Conclusion

As an increasing number of users adopt new hardware equipped with built-in Passkey capabilities, the era of traditional vanilla passwords is drawing to a close. Passkeys emerge as a promising alternative, offering enhanced security. There is no better time than now to integrate the Passkeys feature into your website and application, providing a secure and modern authentication experience for the users.

🔗 References

🖋️ Contribution

This blog is co-written by Pisit Wetchayanwiwat.

If this is the kind of challenges you wanna tackle, Nimble is hiring awesome web and mobile developers to join our team in Bangkok, Thailand, Ho Chi Minh City, Vietnam, and Da Nang, Vietnam✌️

Join Us

Recommended Stories:

Accelerate your digital transformation.

Subscribe to our newsletter and get latest news and trends from Nimble