Back to all articles

Setting Up TOTP Two-Factor Authentication in Ruby on Rails Application

Two-factor authentication should be a standard feature  in today’s world — especially if your app is processing sensitive data. This guide will walk you through implementing it into your Rails application with the help of Devise and a few other helpful gems.

Prerequisites

This guide assumes that you are already familiar with the basic concepts of Ruby on Rails and Devise and have already set up an app with Devise.

— Existing Ruby on Rails project

— Installed and configured Devise

— Tailwind CSS in the assets pipeline (only for the view part)

— Turbo enabled

I’ll be 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

Let’s start building — Basic configuration

We’ll base our implementation on a great gem called devise-two-factor providing basics of two-factor authentication.

Setting up active record encryption

Devise-two-factor uses ActiveRecord encrypted attributes to add an extra layer of security to the OTP secret. If you have already set up Active Record encryption, skip this step.

If you haven’t, you must generate a key set and configure your application to use it with Rails encrypted credentials or from another source, such as environment variables.

# Generates keyset and outputs it to stdout
rails db:encryption:init

Then you can load the key set using Rails credentials.

# Copy the generated key set into your encrypted credentials file
rails credentials:edit

Or set up environment variables if that is more convenient for your project.

# add those lines to the config/application.rb
config.active_record.encryption.primary_key = ENV['ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY']
config.active_record.encryption.deterministic_key = ENV['ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY']
config.active_record.encryption.key_derivation_salt = ENV['ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT']

Devise-two-factor setup

Add devise-two-factor gem to your project.

bundle add devise-two-factor

And then run the devise-two-factor generator (usually your MODEL will be User).

rails generate devise_two_factor MODEL

This generator will create a migration which adds a few fields to your model.

add_column :users, :otp_secret, :string
add_column :users, :consumed_timestep, :integer
add_column :users, :otp_required_for_login, :boolean

Edit the modelapp/models/MODEL.rb

— Addtwo_factor_authenticatable to the model Devise config

— Remove database_authenticatable from the model Devise config

Check if those changes were successful — this generator may fail if your model is complicated.

Keep in mind that loading both :database_authenticatable and :two_factor_authenticatable in a model is a security issue and should be avoided.

After running the generator, we have to run database migrations.

rails db:migrate

Devise configuration

The next step is configuring Devise params sanitizer to permit otp_attempt in sign_in params.

# app/controllers/application_controller.rb

before_action :configure_permitted_parameters, if: :devise_controller?

# ...

protected

def configure_permitted_parameters
 devise_parameter_sanitizer.permit(:sign_in, keys: [:otp_attempt])
end

If you are using recoverable Devise strategy, make sure to disable automatic login after the password reset. Leaving this behavior enabled will result in a security flaw.

# config/initializers/devise.rb

Devise.setup do |config|
    # ...
    config.sign_in_after_reset_password = false
    # ...
end

The last configuration is the allowed OTP drift, which defines the maximum allowed difference between the client and server clock.

# config/initializers/devise.rb

Devise.otp_allowed_drift = 120 # in seconds

Let’s test it before continuing to the next steps

First, we have to add the :otp_attempt field to the login page.

You can generate custom Devise views if you don’t have them in your app yet.

# skip this step if your app already has custom devise views
rails generate devise:views
# example  attempt field
# app/views/devise/sessions/new.html.erb

<%= f.label :otp_attempt, "Two-factor authentication", class: "block text-sm font-medium mb-2" %>
<%= f.text_field :otp_attempt, id: :otp_attempt,
      autocomplete: "one-time-code",
      placeholder: "one-time code",
      class: "appearance-none border border-gray-300 rounded-md w-full py-2 px-3 text-sm text-gray-800 focus:border-amber-dark focus:ring-0" %>

Then, let’s enable two-factor authentication for a user using the rails console.

$ rails c

Loading development environment (Rails 7.1.3.4)

irb(main):001> u = User.first
  User Load (1.0ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
=> #<User id: 16, first_name: "Admin", last_name: "Adminowski", created_at: "2024-09-06 12:16:01.207888000 +0200", updated_at: "2024-09-09 09:41:09.813245000 +0200", email: "admin@example.com", roles: ["admin"], phone_num...

irb(main):002> u.otp_secret = User.generate_otp_secret
=> "XO4344F564O5S4K2EG2EDW52"

irb(main):003> u.otp_required_for_login = true
=> true

irb(main):004> u.save!
  TRANSACTION (0.2ms)  BEGIN
  User Update (2.5ms)  UPDATE "users" SET "updated_at" = $1, "otp_secret" = $2, "otp_required_for_login" = $3 WHERE "users"."id" = $4  [["updated_at", "2024-09-09 12:10:47.363689"], ["otp_secret", "{\"p\":\"X6Obax6iKfjSLcu9cVVQ0Yp58blDmdhr\",\"h\":{\"iv\":\"H5AzwkKZGK2IYMgL\",\"at\":\"Ee/hOs9DkaVJyIQfllCONg==\",\"e\":\"QVNDSUktOEJJVA==\"}}"], ["otp_required_for_login", true], ["id", 16]]
  TRANSACTION (2.2ms)  COMMIT
=> true

Now, start the app and go to the login page. You can get the OTP code by calling #current_otp on User instance.

irb(main):005> u.current_otp
=> "063980"

If everything is set up correctly, you will be able to log in using your OTP, but you won’t be able to log in without it.

Additional model fields

We’ll add a few more fields to our model to better track changes made to an account.

# rails g migration AddAdditionalTwoFactorParams

class AddAdditionalTwoFactorParams < ActiveRecord::Migration[7.1]
  def change
    add_column :users, :two_factor_app_enabled, :boolean, default: false, null: false
    add_column :users, :two_factor_app_enabled_at, :datetime
  end
end

Thanks to this, we’ll know when the user has enabled two-factor authentication and track what two-factor method they use (in case we want to add other methods like SMS and email in the future).

Account Settings

Now, we’ll add an option to turn two-factor authentication on and off in the account settings.

New options in account settings

First, let’s create a new part of the account settings page for our two-factor settings. Most commonly, the account settings view is located in app/views/devise/registrations/edit.html.erb file; however, its location may differ depending on your custom Devise configuration.

<!-- ... -->
<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"
           xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 512 512"
           xml:space="preserve">
        <path fill="#1A73E8" d="M440,255.99997v0.00006C440,273.12085,426.12085,287,409.00003,287H302l-46-93.01001l49.6507-85.9951
c8.56021-14.82629,27.51834-19.9065,42.34518-11.34724l0.00586,0.0034c14.82776,8.55979,19.90875,27.51928,11.34857,42.34682
L309.70001,225h99.30002C426.12085,225,440,238.87917,440,255.99997z" />
        <path fill="#EA4335" d="M348.00174,415.34897l-0.00586,0.00339c-14.82684,8.55927-33.78497,3.47903-42.34518-11.34723L256,318.01001
l-49.65065,85.99509c-8.5602,14.82629-27.51834,19.90652-42.34517,11.34729l-0.00591-0.00342
c-14.82777-8.55978-19.90875-27.51929-11.34859-42.34683L202.29999,287L256,285l53.70001,2l49.6503,86.00214
C367.91049,387.82968,362.8295,406.78918,348.00174,415.34897z" />
        <path fill="#FBBC04" d="M256,193.98999L242,232l-39.70001-7l-49.6503-86.00212
c-8.56017-14.82755-3.47919-33.78705,11.34859-42.34684l0.00591-0.00341c14.82683-8.55925,33.78497-3.47903,42.34517,11.34726
L256,193.98999z" />
        <path fill="#34A853" d="M248,225l-36,62H102.99997C85.87916,287,72,273.12085,72,256.00003v-0.00006
C72,238.87917,85.87916,225,102.99997,225H248z" />
        <polygon fill="#185DB7" points="309.70001,287 202.29999,287 256,193.98999 " />
      </svg>
      <div class="ml-4 mt-0">
        <div class="text-sm font-medium text-gray-900"><%= t("second_factor.authenticator_app") %></div>
        <div class="mt-1 text-sm text-gray-600 flex items-center">
          <% if current_user.two_factor_app_enabled %>
            <div><%= t("second_factor.enabled") %> <%= current_user.two_factor_app_enabled_at.strftime("%Y.%m.%d") %> <span class="hidden sm:inline"><%= current_user.two_factor_app_enabled_at.strftime("%H:%M") %></span></div>
          <% else %>
            <div><%= t("second_factor.disabled") %></div>
          <% end %>
        </div>
      </div>
    </div>
    <div class="ml-6 mt-0 flex-shrink-0">
      <% if current_user.two_factor_app_enabled %>
        <%= link_to t("second_factor.disable"),
              "#destroy_two_factor_app_path",
              data: {turbo_frame: "modal"},
              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" %>
      <% else %>
        <%= link_to t("second_factor.enable"),
              "#init_two_factor_app_path",
              data: {turbo_frame: "modal"},
              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>
<!-- ... -->

This example uses Tailwind for styling; for now, links are just placeholders; we’ll replace them with proper paths after creating the controller.

Authenticator App settings when it’s disabled Authenticator App settings when it’s disabled

Authenticator App settings when it’s enabled Authenticator App settings when it’s enabled

Skeleton controller

Let’s create a skeleton controller with empty methods for now. As this tutorial progresses, we will add more functionality to it.

# app/controllers/users/second_factor_controller.rb

class Users::SecondFactorController < ApplicationController
  before_action :authenticate_user!
  layout "devise/registrations"

  def initiate_new_app
  end

  def new_app
  end

  def create_app
  end

  def new_destroy_app
  end

  def destroy_app
  end
end

Next, add this controller and its methods to routes in whatever way suits you best.

# config/routes.rb
# this is example on how you can define those routes

get "auth/edit/2fa/app/init" => "users/second_factor#initiate_new_app", :as => :init_new_user_two_factor_app
post "auth/edit/2fa/app/new" => "users/second_factor#new_app", :as => :new_user_two_factor_app
post "auth/edit/2fa/app" => "users/second_factor#create_app", :as => :create_user_two_factor_app
get "auth/edit/2fa/app/destroy" => "users/second_factor#new_destroy_app", :as => :new_destroy_user_two_factor_app
post "auth/edit/2fa/app/destroy" => "users/second_factor#destroy_app", :as => :destroy_user_two_factor_app

Now is a good time to update paths in our account settings view.

<% if current_user.two_factor_app_enabled %>
  <%= link_to t("second_factor.disable"),
        new_destroy_user_two_factor_app, # <-- changed
        data: {turbo_frame: "modal"},
        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" %>
<% else %>
  <%= link_to t("second_factor.enable"),
        init_new_user_two_factor_app, # <-- changed
        data: {turbo_frame: "modal"},
        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 %>

Enabling two-factor authentication app

Let’s consider what a two-factor enabling flow should look like. Below, you’ll find an example flow from one of Lunar Logic apps

  1. User clicks on the Enable button
  2. User confirms their password
  3. User scans QR code or copies secret
  4. User enters generated one time code
  5. Two-factor authentication is enabled

Your requirements might differ, so customize this flow according to your needs (e.g. sending a confirmation email, confirming it with a code sent via SMS, etc.).

Password reconfirmation view

We’ll begin by creating a form object for our password reconfirmation view.

class PasswordConfirmationForm
  include ActiveModel::Model

  attr_accessor :password
end

Now we’ll create a new view that will be used to reconfirm user credentials.

<%= form_with(model: password_confirmation_form, class: "mt-10 ", url: new_user_two_factor_app_path) do |form| %>
  <% if password_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>
        <% password_confirmation_form.errors.each do |error| %>
          <li><%= error.full_message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

  <div class="my-2">
    <%= form.label :password, t("second_factor.enter_password"), class: "block text-base font-medium text-gray-900" %>
    <div class="flex gap-3 mt-2">
    <%= form.password_field :password, id: "password", class: "block shadow rounded-md border border-gray-200 outline-none w-full", autocomplete: "current-password", placeholder: t("second_factor.password") %>
    <%= form.submit t("second_factor.confirm"), 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 SecondFactorController.

# app/controllers/users/second_factor_controller

# ...

def initiate_new_app
  render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: PasswordConfirmationForm.new}
end

# ...

In case of a real app, the form is rendered in a modal using turbo In case of a real app, the form is rendered in a modal using turbo

TOTP initialization

Before enabling two-factor authentication, we must display the OTP secret to the user. We can do that by displaying it as plain text or rendering a provisioning QR code. Let’s create a view dedicated to this purpose.

First, we will need a form object for this view.

class TwoFactorAppEnablementForm
  include ActiveModel::Model

  attr_accessor :password, :otp_code
end

Then we can create a view.

<%= turbo_frame_tag "modal" do %> <!-- this will be rendered in a modal -->
  <%= 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>
    <%= form_with(model: two_factor_app_enablement_form, class: "fixed inset-0 z-10 w-screen overflow-y-auto", url: create_user_two_factor_app_path) do |form| %>
      <%= form.hidden_field :password %>
      <div class="flex min-h-full items-center justify-center p-4 text-center sm:p-0">
        <div class="relative 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="">
            <h1 class="font-medium"><%= t("second_factor.authenticator_app") %></h1>
            <div class="flex justify-center items-center w-full flex-col">
              <div class="flex flex-col mt-4 space-y-4">
              <div class="">
                <% if two_factor_app_enablement_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>
                      <% two_factor_app_enablement_form.errors.each do |error| %>
                        <li><%= error.full_message %></li>
                      <% end %>
                    </ul>
                  </div>
                <% end %>

                  <h3 class="text-base font-semibold leading-6 text-gray-900"><%= t("second_factor.1_download_the_authenticator_app") %></h3>
                  <div class="flex justify-around items-center w-full flex-col space-y-6 mt-3">
                    <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank">
                      <!-- you can find this image in google partner marketing hub -->
                      <%= image_tag("get_it_on_google_play_en.png", class: "w-[9rem]") %>
                    </a>
                    <a href="https://apps.apple.com/pl/app/google-authenticator/id388497605" target="_blank">
                      <!-- you can find this image in apple developer marketing materials -->
                      <%= image_tag("get_it_on_app_store.svg", class: "w-[9rem]") %>
                    </a>
                  </div>
                                </div>
                                <h3 class="text-base font-semibold leading-6 text-gray-900"><%= t("second_factor.2_scan_or_enter_the_code") %></h3>
                                <div class="flex justify-center items-center w-full flex-col space-y-3 mt-3">
                  <div class="text-white w-32">
                    <%= raw(qr_code) %> <!-- see controller file below for qr code generation example -->
                  </div>
                  <div class="font-medium">
                    <%= otp_secret %>
                  </div>
                                </div>
                                <h3 class="text-base font-semibold leading-6 text-gray-900 mt-5"><%= t("second_factor.3_enter_the_generated_code") %></h3>
              </div>
              <%= form.text_field :otp_code, id: "password", class: "my-3 block shadow rounded-md border border-gray-200 outline-none px-3 py-2 w-full", autocomplete: "one-time-code", placeholder: t("second_factor.one_time_code_placeholder") %>
            </div>
            <%= form.submit t("second_factor.enable"), class: "rounded-md 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>
          <%= button_tag t("application.close"), data: {action: "turbo-modal#hideModal"}, type: "button", class: "absolute top-4 right-4 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" %>
        </div>
      </div>
    <% end %>
  <% end %>
<% end %>

Then, we will create a TOTP initialization service. This service will check if the user provided the correct password and generate a new OTP secret — we will need it to create a QR code and display it to the user.

# app/services/second_factor/initialize_app_provisioning.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 InitializeAppProvisioning
    include Dry::Monads[:result] # <-- We are using dry-rb modnads
    include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)

    # @param [User] user
    # @param [String] password
    def call(user:, password:)
      unless user.valid_password?(password)
        return Failure(InvalidPassword.new) # <-- instead you could raise
      end

      if user.two_factor_app_enabled
        return Failure(YouAlreadyHaveSecondFactorAppEnabled.new) # <-- instead you could raise
      end

      user.otp_secret = User.generate_otp_secret
      user.save!

      Success(user) # <-- instead just return a value
    end
  end
end

Next, we will use this service in the controller.

# app/controllers/users/second_factor_controller.rb

# ...

def new_app
  password_confirmation_form = PasswordConfirmationForm.new(password_confirmation_params)

  SecondFactor::InitializeAppProvisioning.new.call(user: current_user, password: password_confirmation_form.password) do |m|
    m.failure do |err|
      password_confirmation_form.errors.add(:base, err.message)
      render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: password_confirmation_form}, status: :unprocessable_entity
    end

    m.success do |user|
      render "devise/registrations/second_factor/new_app", locals: {
        qr_code: "",
        otp_secret: user.otp_secret,
        two_factor_app_enablement_form: TwoFactorAppEnablementForm.new(password: password_confirmation_form.password)
      }
    end
  end
end

# ...

def password_confirmation_params
  params.require(:password_confirmation_form).permit(:password)
end

# ...

But wait, one thing is missing — provisioning QR code generation. Even though devise-two-factor provides a method for generating the provisioning URI, we need another gem to generate the QR code. We will use rqrcode.

bundle add rqrcode

Now we have to update our controller.

# app/controllers/users/second_factor_controller.rb
require "rqrcode"

# ...

def new_app
  password_confirmation_form = PasswordConfirmationForm.new(password_confirmation_params)

  SecondFactor::InitializeAppProvisioning.new.call(user: current_user, password: password_confirmation_form.password) do |m|
    # we are using dry-rb monads with pattern matching here
    # if you don't want to use it you'll have to rewrite this part yourself
    m.failure do |err|
      password_confirmation_form.errors.add(:base, err.message)
      render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: password_confirmation_form}, status: :unprocessable_entity
    end

    m.success do |user|
      render "devise/registrations/second_factor/new_app", locals: {
        qr_code: qr_code(user),
        otp_secret: user.otp_secret,
        two_factor_app_enablement_form: TwoFactorAppEnablementForm.new(password: password_confirmation_form.password)
      }
    end
  end
end

# ...

def password_confirmation_params
  params.require(:password_confirmation_form).permit(:password)
end

# ...

# param [User] user
def qr_code(user)
  provisioning_uri = user.otp_provisioning_uri(user.email, issuer: "Your App Name")
  RQRCode::QRCode.new(provisioning_uri, level: :l).as_svg(fill: :currentColor, color: "020617", viewbox: true)
end

# ...

And here is how the step 3 of our flow looks like.

User can scan the QR code or enter the secret to add a new account to the authenticator app User can scan the QR code or enter the secret to add a new account to the authenticator app

Google Authenticator screenshot Google Authenticator screenshot

Enabling two-factor authentication

So now, after the user scans the QR code, adds the OTP secret to their authentication app, and enters the code into our form, we can proceed with enabling two-factor authentication.

Let’s create a new service to reduce clutter and decouple things from our controllers. I am using dry-rb monads and pattern matchers to keep the code readable and make error handling easier.

# app/services/second_factor/provision_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 ProvisionApp
    include Dry::Monads[:result] # once again we are using dry-rb monads
    include Dry::Matcher.for(:call, with: Dry::Matcher::ResultMatcher)

    # @param [User] user
    # @param [String] password
    # @param [String] otp_code
    def call(user:, password:, otp_code:)
      User.transaction do
        unless user.valid_password?(password)
          return Failure(InvalidPassword.new)
        end

        if user.two_factor_app_enabled
          return Failure(YouAlreadyHaveSecondFactorAppEnabled.new)
        end

        unless user.validate_and_consume_otp!(otp_code)
          return Failure(InvalidOtpCode.new)
        end

        user.otp_required_for_login = true
        user.two_factor_app_enabled = true
        user.two_factor_app_enabled_at = Time.current
        user.save!

        Success(user)
      end
    end
  end
end

After creating the service we can use it in our controller.

# app/controllers/users/second_factor_controller.rb

# ...

def create_app
  two_factor_app_enablement_form = TwoFactorAppEnablementForm.new(two_factor_app_enablement_params)

  SecondFactor::ProvisionApp.new.call(
    user: current_user,
    password: two_factor_app_enablement_form.password,
    otp_code: two_factor_app_enablement_form.otp_code
  ) do |m|
    m.failure(SecondFactor::InvalidPassword) do |err|
      form = PasswordConfirmationForm.new(password: two_factor_app_enablement_form.password)
      form.errors.add(:base, err.message)

      render "devise/registrations/second_factor/initiate_new_app", locals: {
        password_confirmation_form: form
      }, status: :unprocessable_entity
    end

    m.failure do |err|
      form = TwoFactorAppEnablementForm.new(password: two_factor_app_enablement_form.password)
      form.errors.add(:base, err.message)

      render "devise/registrations/second_factor/new_app", locals: {
        qr_code: qr_code(current_user),
        otp_secret: current_user.otp_secret,
        two_factor_app_enablement_form: form
      }, status: :unprocessable_entity
    end

    m.success do
      respond_to do |format|
        format.html do
          redirect_to edit_user_registration_path
        end

        format.turbo_stream do
          # turbo stream refresh refreshes account settings page
          # to immediately show user updated view
          # alternatively we could turbo_stream.replace (but for now this lazy approach is fine :p )

          render turbo_stream: turbo_stream.turbo_stream_refresh_tag(request_id: nil)
        end
      end
    end
  end
end

# ...

def two_factor_app_enablement_params
  params.require(:two_factor_app_enablement_form).permit(:password, :otp_code)
end

# ...

🎉 Step 5 of our flow is ready!

Authenticator App settings when it’s enabled Authenticator App settings when it’s enabled

Our controller so far.

# app/controllers/users/second_factor_controller.rb

require "rqrcode"

class Users::SecondFactorController < ApplicationController
  before_action :authenticate_user!
  layout "devise/registrations"

  def initiate_new_app
    render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: PasswordConfirmationForm.new}
  end

  def new_app
    password_confirmation_form = PasswordConfirmationForm.new(password_confirmation_params)

    SecondFactor::InitializeAppProvisioning.new.call(user: current_user, password: password_confirmation_form.password) do |m|
      m.failure do |err|
        password_confirmation_form.errors.add(:base, err.message)
        render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: password_confirmation_form}, status: :unprocessable_entity
      end

      m.success do |user|
        render "devise/registrations/second_factor/new_app", locals: {
          qr_code: qr_code(user),
          otp_secret: user.otp_secret,
          two_factor_app_enablement_form: TwoFactorAppEnablementForm.new(password: password_confirmation_form.password)
        }
      end
    end
  end

  def create_app
    two_factor_app_enablement_form = TwoFactorAppEnablementForm.new(two_factor_app_enablement_params)

    SecondFactor::ProvisionApp.new.call(
      user: current_user,
      password: two_factor_app_enablement_form.password,
      otp_code: two_factor_app_enablement_form.otp_code
    ) do |m|
      m.failure(SecondFactor::InvalidPassword) do |err|
        form = PasswordConfirmationForm.new(password: two_factor_app_enablement_form.password)
        form.errors.add(:base, err.message)

        render "devise/registrations/second_factor/initiate_new_app", locals: {
          password_confirmation_form: form
        }, status: :unprocessable_entity
      end

      m.failure do |err|
        form = TwoFactorAppEnablementForm.new(password: two_factor_app_enablement_form.password)
        form.errors.add(:base, err.message)

        render "devise/registrations/second_factor/new_app", locals: {
          qr_code: qr_code(current_user),
          otp_secret: current_user.otp_secret,
          two_factor_app_enablement_form: form
        }, status: :unprocessable_entity
      end

      m.success do
        respond_to do |format|
          format.html do
            redirect_to edit_user_registration_path
          end

          format.turbo_stream do
            render turbo_stream: turbo_stream.turbo_stream_refresh_tag(request_id: nil)
          end
        end
      end
    end
  end

  def new_destroy_app
  end

  def destroy_app
  end

  protected

  def password_confirmation_params
    params.require(:password_confirmation_form).permit(:password)
  end

  def two_factor_app_enablement_params
    params.require(:two_factor_app_enablement_form).permit(:password, :otp_code)
  end

  # param [User] user
  def qr_code(user)
    provisioning_uri = user.otp_provisioning_uri(user.email, issuer: "Your App Name")
    RQRCode::QRCode.new(provisioning_uri, level: :l).as_svg(fill: :currentColor, color: "020617", viewbox: true)
  end
end

Disabling two-factor authentication

What how should a two-factor disabling flow look like? You can begin with a straightforward one like this

  1. User enters one time code
  2. Two-factor authentication is disabled

Make sure to tailor it to your needs (ex. additional password reconfirmation, sending code via SMS or email)

One time code entry form

First let’s create form object for OTP confirmation.

class OtpConfirmationForm
  include ActiveModel::Model

  attr_accessor :otp_code
end

Then we will use it in OTP confirmation view.

<!-- app/views/devise/registrations/second_factor/_new_destroy_app.html.erb -->

<%= form_with(model: otp_confirmation_form, class: "mt-10", url: destroy_user_two_factor_app_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 gap-3 mt-2">
    <%= form.text_field :otp_code, id: "otp", class: "block shadow rounded-md border border-gray-200 outline-none px-3 py-2 w-full", autocomplete: "one-time-password", placeholder: t("second_factor.one_time_or_backup_code") %>
    <%= form.submit t("second_factor.disable"), 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 in the controller.

# app/controllers/users/second_factor_controller.rb

# ...

def new_destroy_app
  render "devise/registrations/second_factor/new_destroy_app", locals: {otp_confirmation_form: OtpConfirmationForm.new}
end

# ...

Our form should be ready now!

Form is displayed inside modal using turbo Form is displayed inside modal using turbo

Disabling two-factor authentication

As usual, we’ll begin by creating a service.

# 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
        unless user.validate_and_consume_otp!(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

        user.save!

        Success(user)
      end
    end
  end
end

Then we’ll use it in the second factor controller.

# app/controllers/users/second_factor_controller.rb

# ...

def destroy_app
  otp_confirmation_form = OtpConfirmationForm.new(otp_confirmation_params)

  SecondFactor::DisableApp.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_destroy_app", locals: {otp_confirmation_form: form}, status: :unprocessable_entity
    end

    m.success do
      respond_to do |format|
        format.html do
          redirect_to edit_user_registration_path
        end

        format.turbo_stream do
          render turbo_stream: turbo_stream.turbo_stream_refresh_tag(request_id: nil)
        end
      end
    end
  end
end

# ...

def otp_confirmation_params
  params.require(:otp_confirmation_form).permit(:otp_code)
end

And now we can disable two-factor authentication 🎉

Authenticator App settings when it’s disabled Authenticator App settings when it’s disabled

Complete controller should be similar to this.

# app/controllers/users/second_factor_controller.rb

require "rqrcode"

class Users::SecondFactorController < ApplicationController
  before_action :authenticate_user!
  layout "devise/registrations"

  def initiate_new_app
    render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: PasswordConfirmationForm.new}
  end

  def new_app
    password_confirmation_form = PasswordConfirmationForm.new(password_confirmation_params)

    SecondFactor::InitializeAppProvisioning.new.call(user: current_user, password: password_confirmation_form.password) do |m|
      m.failure do |err|
        password_confirmation_form.errors.add(:base, err.message)
        render "devise/registrations/second_factor/initiate_new_app", locals: {password_confirmation_form: password_confirmation_form}, status: :unprocessable_entity
      end

      m.success do |user|
        render "devise/registrations/second_factor/new_app", locals: {
          qr_code: qr_code(user),
          otp_secret: user.otp_secret,
          two_factor_app_enablement_form: TwoFactorAppEnablementForm.new(password: password_confirmation_form.password)
        }
      end
    end
  end

  def create_app
    two_factor_app_enablement_form = TwoFactorAppEnablementForm.new(two_factor_app_enablement_params)

    SecondFactor::ProvisionApp.new.call(
      user: current_user,
      password: two_factor_app_enablement_form.password,
      otp_code: two_factor_app_enablement_form.otp_code
    ) do |m|
      m.failure(SecondFactor::InvalidPassword) do |err|
        form = PasswordConfirmationForm.new(password: two_factor_app_enablement_form.password)
        form.errors.add(:base, err.message)

        render "devise/registrations/second_factor/initiate_new_app", locals: {
          password_confirmation_form: form
        }, status: :unprocessable_entity
      end

      m.failure do |err|
        form = TwoFactorAppEnablementForm.new(password: two_factor_app_enablement_form.password)
        form.errors.add(:base, err.message)

        render "devise/registrations/second_factor/new_app", locals: {
          qr_code: qr_code(current_user),
          otp_secret: current_user.otp_secret,
          two_factor_app_enablement_form: form
        }, status: :unprocessable_entity
      end

      m.success do
        respond_to do |format|
          format.html do
            redirect_to edit_user_registration_path
          end

          format.turbo_stream do
            render turbo_stream: turbo_stream.turbo_stream_refresh_tag(request_id: nil)
          end
        end
      end
    end
  end

  def new_destroy_app
    render "devise/registrations/second_factor/new_destroy_app", locals: {otp_confirmation_form: OtpConfirmationForm.new}
  end

  def destroy_app
    otp_confirmation_form = OtpConfirmationForm.new(otp_confirmation_params)

    SecondFactor::DisableApp.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_destroy_app", locals: {otp_confirmation_form: form}, status: :unprocessable_entity
      end

      m.success do
        respond_to do |format|
          format.html do
            redirect_to edit_user_registration_path
          end

          format.turbo_stream do
            render turbo_stream: turbo_stream.turbo_stream_refresh_tag(request_id: nil)
          end
        end
      end
    end
  end

  protected

  def password_confirmation_params
    params.require(:password_confirmation_form).permit(:password)
  end

  def two_factor_app_enablement_params
    params.require(:two_factor_app_enablement_form).permit(:password, :otp_code)
  end

  def otp_confirmation_params
    params.require(:otp_confirmation_form).permit(:otp_code)
  end

  # param [User] user
  def qr_code(user)
    provisioning_uri = user.otp_provisioning_uri(user.email, issuer: "Your App Name")
    RQRCode::QRCode.new(provisioning_uri, level: :l).as_svg(fill: :currentColor, color: "020617", viewbox: true)
  end
end

Showing OTP field only to users who have OTP enabled

We can achieve that in multiple ways, but in this tutorial, we’ll do that in a lazy way.

To begin we have to modify Devise SessionController. If you don’t have custom Devise controllers you can set them up by running Rails generator and following its instructions.

# if you already have custom devise controllers you can skip this part
rails generate devise:controllers users -c=sessions

Let’s create OTP attempt partial.

<div class="mb-3" data-login-target="otpField">
  <%= label :user, :otp_attempt, t("second_factor.two_factor_authentication"), class: "block text-sm font-medium mb-2" %>
  <%= text_field :user, :otp_attempt, id: :otp_attempt,
        autocomplete: "one-time-code",
        placeholder: t("second_factor.one_time_or_backup_code"),
        class: "appearance-none border border-gray-300 rounded-md w-full py-2 px-3 text-sm text-gray-800 focus:border-amber-dark focus:ring-0" %>
</div>

Add an outlet where :otp_attempt field will appear.

<!-- ... -->
<div id="otp_attempt_outlet">
</div>
<!-- ... -->

Then, we will render :otp_attempt from SessionsController using turbo if the user has enabled two-factor authentication.

class Users::SessionsController < Devise::SessionsController
	
	# ...
	
  # POST /resource/sign_in
  def create
    # if the super is not called last devise breaks so we can't return early
    if sign_in_params[:otp_attempt].nil?

      user = attempting_user

      if user&.otp_required_for_login
        respond_to do |format|
          format.turbo_stream do
            render turbo_stream: turbo_stream.append("otp_attempt_outlet", partial: "devise/sessions/otp_attempt")
          end
        end

        return
      end
    end

    super
  end

	# ...

  protected

  def attempting_user
    email = sign_in_params[:email]
    password = sign_in_params[:password]

    user = User.find_by(email: email)

    unless user&.valid_password?(password)
      return nil
    end

    user
  end
end

Result

Login page without OTP attempt field Login page without OTP attempt field

OTP attempt field appears if user has enabled two factor authentication OTP attempt field appears if user has enabled two-factor authentication

Improvements to consider

Backup Codes

We should provide users with a secondary way of two-factor authentication in case they lose a primary device or it malfunctions. Backup codes are a good and easy solution to this problem. If you are interested in details of implementing backup codes please see this post.

SMS Authentication

Even though sending one-time codes through SMS is less secure than the Authenticator App, it’s still prevalent among many users. You can consider adding it as a standalone two-factor authentication method or a backup measure in case the user loses access to the Authenticator App.

Admin Panel

Administration / Customer support should have an option to disable two-factor authentication. However, verifying user identity before doing so is of the utmost importance—do not skimp on staff training; humans are the weakest link, after all.

Share this article: