Back to all articles

Adding Backup Codes Two-Factor Authentication to Ruby on Rails app

This is the second part of a post-series on two-factor authentication in Ruby on Rails. This article builds upon what was created in the previous article.

In this tutorial, I’m using dry-rb monads and result matcher, so if you want this code to work without additional customization, you’ll need to install dry-monads and dry-matcher

Why backup codes?

We should provide users with a secondary way of two-factor authentication in case they lose the primary device or it malfunctions. Backup codes are a good and easy solution to this problem.

Before writing any code, we should think about how the backup code generation flow should look. Do we want the user to reconfirm their password? Do we want the user to provide the OTP code first?

This tutorial will implement this flow

  1. User eneters valid OTP code
  2. Codes are generated

As usual, you should customize this flow according to your needs.

Let’s start building

We’ll base our implementation on the two_factor_backupable Devise module provided by the devise-two-factor gem.

First, we have to add the Devise module to our model. In this step, we can also configure the length and number of backup codes.

# app/models/user.rb

# ...			

devise :two_factor_backupable, 
  otp_backup_code_length: 10, otp_number_of_backup_codes: 10

# ...

Then we have to change the Devise configuration in config/initializers/devise.rb.

# config/initializers/devise.rb

# ...

Devise.setup do |config|

# ...

	config.warden do |manager|
		# replace user with your model
		manager.default_strategies(scope: :user).unshift :two_factor_backupable
	  manager.default_strategies(scope: :user).unshift :two_factor_authenticatable
	end

# ...

end

And the only thing that needs to be added is migration.

# rails g migration AddDeviseTwoFactorBackupableToUsers
# this migration is PostgreSQL specific
# replace users with your model
class AddDeviseTwoFactorBackupableToUsers < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :otp_backup_codes, :string, array: true
  end
end

Now we can try our implementation.

rails c

Loading development environment (Rails 7.1.3.4)

irb(main):001> u = User.first
  User Load (0.9ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<User id: 1, first_name: "Admin", last_name: "Adminowski", created_at: "2024-09-13 14:49:47.354067000 +0200", updated_at: "2024-09-16 19:28:05.270613000 +0200", email: "admin@example.com", roles: ["admin"], phone_numb...

irb(main):002> u.generate_otp_backup_codes!
=> ["75ffbf8b36", "4319413b2d", "d21e0b8be6", "68a12d025d", "d3acd1b359", "3698966edb", "ad5f850307", "18d80e891d", "7ff35b1162", "c1908cfb8f"]

irb(main):003> u.save!
  TRANSACTION (0.2ms)  BEGIN
  User Update (2.4ms)  UPDATE "users" SET "updated_at" = $1, "otp_backup_codes" = $2 WHERE "users"."id" = $3  [["updated_at", "2024-09-17 07:44:51.011240"], ["otp_backup_codes", "{$2a$13$ZK6TqBopl7Hu0pyr32mGb.o70sXLXuDcItNMKR7UcjTKjSuE.EHZq,$2a$13$XlgLxXX3OCO.PytbE4uqc.KzP2s1lGlQSgge.5ntl9BIu2MqP.Ry2,$2a$13$r9WFYt4NEXd5.x5decLh3eB2Int5j8Q8AMIb4ZK5z35akKVgrTgMa,$2a$13$wdT.liV1.YvkGZ5HmHyjYOyRDnlRoYB85pShEv46mgyEcpFVvbnw.,$2a$13$dzcbyywPXPrCqHM/oPVVcuFNkeeFi2qeqH0efxLLxIMzorMj1VS4i,$2a$13$2l78aKuuBbgVeHl86sFnxOzr5dBHu.v7b9bi/.H5BNEWSgorRNhdC,$2a$13$kMR4PAErkjPXNk8fYpCIT.pmvGA/TbdQucBThMHVaQRNxQISEuMXG,$2a$13$xxF5jCM6BmZlRrvvMh.n2.Udk43PxjMV5dCTofqty9woznSCzJWxq,$2a$13$jFgjCXtzFSKX3c7vjOviqek9k8OPbRtNuizUZo6xOzDIVJN6QaYHK,$2a$13$1.LQqZA2PPKQW7F9bX3r5eqHL7Qrp041dAvxk3IBXGrrkVdjmGn8G}"], ["id", 1]]
  TRANSACTION (6.5ms)  COMMIT
=> true

Codes will be visible only when we call #generate_otp_backup_codes! Later, they will be stored in the database as hashes, so there is no way to display them again. We can consume a code using the #invalidate_otp_backup_code! method. Remember to save the user after calling it to remove the used backup code from the database.

irb(main):004> u.invalidate_otp_backup_code! "75ffbf8b36"
=> true

irb(main):005> u.otp_backup_codes.count
=> 9

irb(main):006> u.save!
  TRANSACTION (0.2ms)  BEGIN
  User Update (0.4ms)  UPDATE "users" SET "updated_at" = $1, "otp_backup_codes" = $2 WHERE "users"."id" = $3  [["updated_at", "2024-09-17 07:56:14.787430"], ["otp_backup_codes", "{$2a$13$XlgLxXX3OCO.PytbE4uqc.KzP2s1lGlQSgge.5ntl9BIu2MqP.Ry2,$2a$13$r9WFYt4NEXd5.x5decLh3eB2Int5j8Q8AMIb4ZK5z35akKVgrTgMa,$2a$13$wdT.liV1.YvkGZ5HmHyjYOyRDnlRoYB85pShEv46mgyEcpFVvbnw.,$2a$13$dzcbyywPXPrCqHM/oPVVcuFNkeeFi2qeqH0efxLLxIMzorMj1VS4i,$2a$13$2l78aKuuBbgVeHl86sFnxOzr5dBHu.v7b9bi/.H5BNEWSgorRNhdC,$2a$13$kMR4PAErkjPXNk8fYpCIT.pmvGA/TbdQucBThMHVaQRNxQISEuMXG,$2a$13$xxF5jCM6BmZlRrvvMh.n2.Udk43PxjMV5dCTofqty9woznSCzJWxq,$2a$13$jFgjCXtzFSKX3c7vjOviqek9k8OPbRtNuizUZo6xOzDIVJN6QaYHK,$2a$13$1.LQqZA2PPKQW7F9bX3r5eqHL7Qrp041dAvxk3IBXGrrkVdjmGn8G}"], ["id", 1]]
  TRANSACTION (1.9ms)  COMMIT
=> true

New option in account settings

We can begin by adding new methods to the SecondFactorController.

# app/controllers/users/second_factor_controller.rb

# ...

class Users::SecondFactorController < ApplicationController

# ...

	def new_codes
	end
	
	def create_codes
	end
	
# ...
end

And updating config/routes.rb.

# config/routes.rb

# ...

get "auth/edit/2fa/codes" => "users/second_factor#new_codes", :as => :new_user_backup_codes
post "auth/edit/2fa/codes" => "users/second_factor#create_codes", :as => :create_user_backup_codes

# ...

Now let’s add a new entry in the account settings.

<div class="mt-5">
  <div class="rounded-md bg-gray-100 px-6 py-5 flex items-start justify-between">
    <div class="flex items-start">
      <svg class="h-10 w-10 text-red-700" xmlns="http://www.w3.org/2000/svg" viewBox="0 -960 960 960" fill="currentColor"><path d="M480-80q-83 0-156-31.5T197-197q-54-54-85.5-127T80-480q0-83 31.5-156T197-763q54-54 127-85.5T480-880q83 0 156 31.5T763-763q54 54 85.5 127T880-480q0 83-31.5 156T763-197q-54 54-127 85.5T480-80ZM364-182l48-110q-42-15-72.5-46.5T292-412l-110 46q23 64 71 112t111 72Zm-72-366q17-42 47.5-73.5T412-668l-46-110q-64 24-112 72t-72 112l110 46Zm188 188q50 0 85-35t35-85q0-50-35-85t-85-35q-50 0-85 35t-35 85q0 50 35 85t85 35Zm116 178q63-24 110.5-71.5T778-364l-110-48q-15 42-46 72.5T550-292l46 110Zm72-368 110-46q-24-63-71.5-110.5T596-778l-46 112q41 15 71 45.5t47 70.5Z" /></svg>
      <div class="ml-4 mt-0">
        <div class="text-sm font-medium text-gray-900"><%= t("second_factor.backup_codes") %></div>
        <div class="mt-1 text-sm text-gray-600 flex items-center">
          <% if resource.otp_required_for_login %>
            <% if resource.otp_backup_codes.nil? %>
              <div><%= t("second_factor.not_generated_yet") %></div>
            <% else %>
              <div><%= t("second_factor.count1_10_left", count1: resource.otp_backup_codes.count) %></div>
            <% end %>
          <% else %>
            <div><%= t("second_factor.disabled_fem") %></div>
          <% end %>
        </div>
      </div>
    </div>
    <div class="ml-6 mt-0 flex-shrink-0">
      <% if resource.otp_required_for_login %>
        <%= link_to t("second_factor.generate"),
              new_user_backup_codes_path,
              data: {turbo_frame: "modal", turbo_prefetch: false},
              class: "inline-flex items-center rounded-md bg-white px-4 py-2 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50" %>
      <% end %>
    </div>
  </div>
</div>

Here is the result.

Backup codes when user already has generated codes Backup codes when user already has generated codes

Backup codes when user hasn’t generated codes yet Backup codes when user hasn’t generated codes yet

Generating codes

OTP Confirmation

As outlined earlier in our flow, we will begin with the OTP confirmation form.

First we’ll create a form object.

# this might already exist in your application if you've followed steps from previous post
class OtpConfirmationForm
  include ActiveModel::Model

  attr_accessor :otp_code
end

And then we’ll create form.

<!-- devise/registrations/second_factor/new_codes -->

<%= form_with(model: otp_confirmation_form, class: "mt-10", url: create_user_backup_codes_path) do |form| %>
  <% if otp_confirmation_form.errors.any? %>
    <div id="error_explanation" class="relative z-1 bg-red-50 text-red-500 px-3 py-2 font-medium rounded-lg mt-3">
      <ul>
        <% otp_confirmation_form.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="my-2">
      <%= form.label :otp_code, t("second_factor.enter_code"), class: "block text-base font-medium text-gray-900" %>
    <div class="flex mt-2 gap-3 align-center">
      <%= form.text_field :otp_code, id: "otp_code", class: "block shadow rounded-md border border-gray-200 outline-none w-full", autocomplete: "one-time-code", placeholder: t("second_factor.one_time_or_backup_code") %>
      <%= form.submit t("second_factor.generate"), class: "rounded-md cursor-pointer bg-amber group-aria-[busy=true]/frame:bg-gray-300 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-amber-dark focus-visible:outline-offset-2" %>
    </div>
  </div>
<% end %>

And update the controller.

# app/controllers/users/second_factor_controller.rb

# ...

def new_codes
	render "devise/registrations/second_factor/new_codes"
end

# ...

Rendered form Rendered form

Codes generation

Let’s start by creating a view for displaying codes.

<%= turbo_frame_tag "modal" do %>
  <%= tag.div(
        data: {controller: "turbo-modal"},
        class: "relative z-10",
        role: "dialog",
        aria: {labbeledby: "modal-title", modal: true}
      ) do %>
    <div class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity"></div>

    <div class="fixed inset-0 z-10 w-screen overflow-y-auto">
      <div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
        <div class="transform overflow-hidden rounded-lg bg-white px-4 pb-4 pt-5 text-left shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-sm sm:p-6">
          <div class="flex flex-col items-center space-y-4">
            <%= button_tag t("application.close"), data: {action: "turbo-modal#hideModal"}, type: "button", class: "self-end rounded-md bg-amber px-3 py-2 text-sm font-semibold text-white shadow-sm hover:opacity-90 focus-visible:outline focus-visible:outline-2 focus-visible:outline-amber-dark focus-visible:outline-offset-2" %>
            <h1 class="font-medium text-lg"><%= t("second_factor.backup_codes") %></h1>
            <div class="flex justify-center items-center w-full mt-4">
              <div class="grid grid-cols-2 gap-4">
                <% codes.each_slice((codes.size / 2.0).round) do |column| %>
                  <div>
                    <% column.each do |code| %>
                      <p><%= code %></p>
                    <% end %>
                  </div>
                <% end %>
              </div>
            </div>
            <div class="rounded-md bg-yellow-50 p-4 mt-4">
              <div class="flex">
                <div class="flex-shrink-0">
                  <svg class="h-5 w-5 text-yellow-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
                    <path fill-rule="evenodd" d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z" clip-rule="evenodd" />
                  </svg>
                </div>
                <div class="ml-3">
                  <h3 class="text-sm font-medium text-yellow-800"><%= t("second_factor.warning") %></h3>
                  <div class="mt-2 text-sm text-yellow-700">
                    <p><%= t("second_factor.save_codes_or_print") %></p>
                  </div>
                </div>
              </div>
            </div>
          </div>
        </div>
      </div>
    </div>
  <% end %>
<% end %>

Then we can create some logic.

# app/services/second_factor/generate_backup_codes.rb
#
# this service uses dry-rb monads
# however it is not necessary - just my preference
# feel free to return normal values and raise exceptions
#
# this service returns custom errors
# create them if you want to use them
module SecondFactor
  class GenerateBackupCodes
    include Dry::Monads[:result]
    include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)

    # @param [User] user
    # @param [String] otp_code
    def call(user:, otp_code:)
      User.transaction do
        unless user.two_factor_app_enabled
          return Failure(YouNeedAtLeastOneTwoFactorMethodEnabled.new)
        end

        unless user.validate_and_consume_otp!(otp_code) || user.invalidate_otp_backup_code!(otp_code)
          return Failure(InvalidOtpCodeOrBackupCode.new)
        end

        codes = user.generate_otp_backup_codes!
        user.save!

        Success(codes)
      end
    end
  end
end

And use it in the controller.

# app/controllers/users/second_factor_controller.rb

# ...

def create_codes
  otp_confirmation_form = OtpConfirmationForm.new(otp_confirmation_params)

  SecondFactor::GenerateBackupCodes.new.call(
    user: current_user,
    otp_code: otp_confirmation_form.otp_code
  ) do |m|
    m.failure do |err|
      form = OtpConfirmationForm.new
      form.errors.add(:base, err.message)
      render "devise/registrations/second_factor/new_codes", locals: {otp_confirmation_form: form, sms_sent: current_user.two_factor_sms_enabled}, status: :unprocessable_entity
    end

    m.success do |codes|
      render "devise/registrations/second_factor/show_codes", locals: {codes: codes}
    end
  end
end

# ...

def otp_confirmation_params # if you've followed previous tutorial you probably already have this method in the controller
  params.require(:otp_confirmation_form).permit(:otp_code)
end

# ...

Show codes view in action Show codes view in action

Adjustments to other services

Since we’ve just added a new two-factor authentication method, we’ll have to adjust a few services from the previous tutorial to handle using backup codes instead of OTP correctly.

# app/services/second_factor/disable_app.rb
#
# this service uses dry-rb monads
# however it is not necessary - just my preference
# feel free to return normal values and raise exceptions
#
# this service returns custom errors
# create them if you want to use them
module SecondFactor
  class DisableApp
    include Dry::Monads[:result]
    include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)

    # @param [User] user
    # @param [String] otp_code
    def call(user:, otp_code:)
      User.transaction do
	      # we have to add option to disable OTP using backup codes
        unless user.validate_and_consume_otp!(otp_code) || user.invalidate_otp_backup_code!(otp_code)
          return Failure(InvalidOtpCodeOrBackupCode.new)
        end

        user.two_factor_app_enabled = false
        user.two_factor_app_enabled_at = nil

        user.otp_secret = User.generate_otp_secret
        user.otp_required_for_login = false
        
        # also we can consider removing backup codes when disabling 2FA
        user.otp_backup_codes = nil

        user.save!

        Success(user)
      end
    end
  end
end

Conclusion

In this tutorial, we have extended our two-factor authentication implementation by adding backup codes using the devise-two-factor :two_factor_backupable Devise module.

With these changes, your application increases security with a robust two-factor authentication setup and ensures users are never locked out of their accounts.

Make sure to customize and extend the provided code to best suit your unique requirements - your threat model and security needs might differ. If you ever get stuck, be sure to write a comment.

Share this article: