Creating Passkey Authentication in a Rails 7 Application

  • March 27, 2024
  • Jack Curtis
  • 5 min read

If you're looking to allow your users to log into your Rails 7 app with passkeys, well searched.

This article is specifically about passkeys, which are a form of passwordless login that replaces passwords with secure, per-device keys (the eponymous passkeys) which are handled by the device's operating system.

As of this writing, up-to-date Windows, macOS, iOS, and Android all support passkeys. Linux users with the correct browser or browser extension can also use passkeys.

This widespread support is due to the standard called FIDO2, which covers two components: WebAuthn and CTAP2. WebAuthn handles communication between a relying party (your app) and a credential provider (we'll use Bitwarden's, while CTAP2 handles key management and communication between the browser and the operating system.

We won't need to worry about either of these standards, since using Bitwarden as a passkey provider means we can use their open-source library to handle the details.

Rails 7 Passkey Authentication Example

Now that we have a concept for what passkey auth is and how it works, let's implement it in a Rails app. You can find the repo for our example at

We are going to implement a passwordless strategy using Devise. For the uninitiated: Devise is an authentication gem commonly used in Rails applications. It provides several off-the-shelf strategies which abstract the core concepts typically encountered in conventional web authentication.

The core functionality of Devise focuses on the username/password authentication paradigm. Here we will add a custom strategy to Devise using passkeys rather than username/password.

As we mentioned earlier, Bitwarden (specifically is a passkey provider which manages both sides of the passkey management flow (WebAuthn and CTAP2). The first step of our process is to create an account and an application with

Passwordless setup screen
Passwordless dashboard

You will be given three values: An API URL, an API private key, and an API public key. Create a .env file and provide these values:


Set up your database and run the app locally:

rails db:create
rails db:migrate
rails s

Install Devise and associate it with a User model. I recommend removing the password field before running the migration; otherwise Devise will create an automatic presence validation on the password, which we will not be using.

The migration file that Devise generates, after we have removed the password field, should look something like this:

# frozen_string_literal: true

class DeviseCreateUsers < ActiveRecord::Migration[7.1]
  def change
    create_table :users, id: :uuid do |t|
      ## Passkey authenticatable
      t.string :email,              null: false, default: ""
      ## Rememberable
      t.datetime :remember_created_at

      ## Trackable
      t.integer  :sign_in_count, default: 0, null: false
      t.datetime :current_sign_in_at
      t.datetime :last_sign_in_at
      t.string   :current_sign_in_ip
      t.string   :last_sign_in_ip

      ## Confirmable
      t.string :unconfirmed_email
      t.string   :confirmation_token
      t.datetime :confirmed_at
      t.datetime :confirmation_sent_at

      t.timestamps null: false

    add_index :users, :email,                unique: true
    add_index :users, :confirmation_token,   unique: true

Install the Bitwarden JS client using your choice of package manager. The example uses import-maps.

Add our custom passkey Devise module and strategy under the lib/devise directory.


require Rails.root.join('lib/devise/strategies/passkey_authenticatable')

module Devise  
    module Models  
        module PasskeyAuthenticatable  
            extend ActiveSupport::Concern  


module Devise
  module Strategies
    class PasskeyAuthenticatable < Authenticatable
      def valid?

      def authenticate!
        token = params[:token]
        res =['BITWARDEN_PASSWORDLESS_API_URL'] + '/signin/verify', 
          body: JSON.generate({
            token: token
          headers: {
            "Content-Type" => "application/json"

      json = JSON.parse(res.body)
      if json["success"]

Warden::Strategies.add(:passkey_authenticatable, Devise::Strategies::PasskeyAuthenticatable)

Generate the Devise controller for sessions and configure your routes.


devise_for :users, controllers: {
  sessions: "users/sessions"

Replace app/controllers/users/sessions.rb with the following:

# frozen_string_literal: true

class Users::SessionsController < Devise::SessionsController
  before_action :configure_sign_in_params, only: [:create]


  def configure_sign_in_params
    devise_parameter_sanitizer.permit(:sign_in, keys: [:token])

Create a Stimulus controller at app/javascript/controllers/passwordless_controller.js:

import { Controller } from "@hotwired/stimulus"
import { Client } from '@passwordlessdev/passwordless-client';

export default class extends Controller {
  static targets = [ "email" ]

  connect() {
    this.client = new Client({ apiKey: window.VAULT_ENV.BITWARDEN_PASSWORDLESS_API_PUBLIC_KEY });
    this.csrf_token = document.querySelector('meta[name="csrf-token"]').content

  async register() {
    const email = this.emailTarget.value
    const { token: registerToken } = await fetch('/api/registrations', {
      method: 'post',
      headers: {
        'content-type': 'application/json'
      body: JSON.stringify({
        email: email,
    }).then(r => r.json())

    const { token, error } = await this.client.register(registerToken)
    if (token) {
      await this.verifyUser(token)

    if (error) {

  async login() {
    // Generate a verification token for the user.
    const { token, error } = await this.client.signinWithAlias(this.emailTarget.value);

    if (token) {
      await this.verifyUser(token)

  async verifyUser(token) {
    const verifiedUser = await fetch('/users/sign_in', {
      method: 'post',
      headers: {
        'content-type': 'application/json'
      body: JSON.stringify({
        authenticity_token: this.csrf_token
    }).then((r) => r.json());

    if ( {

And insert the controller in the HTML at app/views/users/new.html.erb:

<h1>Sign Up</h1>

<% if current_user %>
  <h2>You're logged in!</h2>

  <%= button_to(
          "Log Out",
          method: :delete
        ) %>
<% else %>
  <div data-controller="passwordless">
    <input data-passwordless-target="email" type="email">

    <button data-action="click->passwordless#register">
      Create Account

    <button data-action="click->passwordless#login">
      Log In
<% end %>

Navigate to your new combined signup/login page and test it out!


Learn more about how The Gnar builds Ruby on Rails applications.

If you liked this article please subscribe to our newsletter

Be assured that we will not sell, trade or otherwise share your information with any other party.

Interested in building with us?