RUBY ON RAILS · 20 MIN READ ·

Rails 2FA (TOTP): Two-Factor Authentication with ROTP, Backup Codes, and Encrypted Secrets

Rails 2FA with TOTP: implement two-factor authentication using ROTP, generate backup codes, encrypt secrets with ActiveRecord, and rate-limit recovery.

Rails 2FA (TOTP): Two-Factor Authentication with ROTP, Backup Codes, and Encrypted Secrets

A SaaS founder called me last October in the specific kind of panic that only a credential-stuffing wave produces. Attackers were spraying an old breach dump against their login form, hitting real accounts, and quietly changing bank details on customer profiles. Passwords were long, hashing was bcrypt, and rate limiting existed. None of it mattered — the credentials were legitimate, just stolen from somewhere else. The only fix that actually worked was turning on Rails 2FA the next morning and forcing enrollment for every account with admin access. The credential spray kept coming for three more weeks, and not one attacker got past the second factor.

After nineteen years of watching this same movie play out for different clients, the pattern is boringly consistent: passwords will eventually leak, users will reuse them, and the only serious defence at the login boundary is a second factor. This post is the exact Rails 2FA implementation I install using TOTP (Time-based One-Time Passwords) — the codes you read out of Google Authenticator, 1Password, or Authy — with the ROTP gem, encrypted secrets, backup codes, and the rate-limiting bits that stop people from brute-forcing the six-digit challenge.

Passkeys are strictly better if your users will adopt them, and I wrote about that in Rails Passkeys with WebAuthn. But TOTP still wins on universality: every phone has an authenticator app, every enterprise SSO tool supports it, and every compliance framework accepts it. Rails 2FA with TOTP is the pragmatic default when you need one factor beyond a password today.

What Rails 2FA With TOTP Actually Is

TOTP is defined by RFC 6238. The server and the client share a random secret at enrollment time. Every 30 seconds both sides run HMAC-SHA1 over (secret, floor(unix_time / 30)), truncate the result to six digits, and compare. If the numbers match, the user has proven possession of a device holding the same secret. That is it — no network calls at verification time, no push notifications, no SMS.

The reason Rails 2FA with TOTP is durable is that the shared secret never leaves the two endpoints after enrollment. There is no SMS to intercept, no push notification service to compromise, no OAuth relationship with a third-party identity provider. The failure modes are narrow: the user loses their phone, the server database leaks, or someone shoulder-surfs the six-digit code within its 30-second window. All three have specific mitigations I will show below.

Three things this post assumes you already have: a working user authentication flow (Devise, authenticate in Rails 8, or your own), HTTPS everywhere, and the rate limiting from Rack::Attack in place on your login endpoints. If any of those are missing, fix them first — 2FA on top of an unrate-limited login form is theater.

Choosing the Library: ROTP, Not devise-two-factor

The Ruby ecosystem has two real choices. The rotp gem is a pure implementation of RFC 6238 with no framework opinions — it generates secrets, provisions QR-code URIs, and verifies codes. The devise-two-factor gem wraps rotp in a Devise strategy with database columns and encrypted secret handling baked in.

I install rotp directly on almost every project. The reason is control: devise-two-factor couples your 2FA lifecycle to Devise’s password lifecycle in ways that make it awkward to add backup codes, remember-this-device tokens, or admin-forced re-enrollment. Ninety lines of Ruby against the rotp primitives gives you the full behavior with none of the assumptions.

# Gemfile
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 3.0"  # only needed for the enrollment QR image

rqrcode is optional — you can render the QR on the client with a JS library instead — but generating the SVG server-side keeps the secret out of the browser’s JavaScript context, which I consider worth the ~2 KB payload.

The Database Schema for Rails 2FA

Store the secret and the enrollment state on the user, and put backup codes in a separate table so you can invalidate them individually. Never store the plaintext TOTP secret; encrypt it with ActiveRecord::Encryption so a stolen database dump does not immediately equal universal 2FA bypass.

# db/migrate/20260821120000_add_two_factor_to_users.rb
class AddTwoFactorToUsers < ActiveRecord::Migration[8.0]
  def change
    change_table :users do |t|
      t.string   :otp_secret_ciphertext
      t.datetime :otp_enabled_at
      t.datetime :otp_last_used_at
      t.integer  :failed_otp_attempts, null: false, default: 0
      t.datetime :otp_locked_until
    end

    create_table :backup_codes do |t|
      t.references :user, null: false, foreign_key: true
      t.string     :code_digest, null: false
      t.datetime   :used_at
      t.timestamps
    end

    add_index :backup_codes, [:user_id, :code_digest], unique: true
  end
end

The otp_secret lives on User as an encrypted attribute:

# app/models/user.rb
class User < ApplicationRecord
  encrypts :otp_secret

  has_many :backup_codes, dependent: :destroy

  def otp_enabled?
    otp_enabled_at.present?
  end

  def otp_locked?
    otp_locked_until.present? && otp_locked_until.future?
  end
end

ActiveRecord::Encryption uses your config/credentials.yml.enc key ring, which means backup rotation is possible without re-encrypting every row — see the Rails credentials guide for the mechanics. In practice: encrypted secrets, deterministic backups, no plaintext on disk. That is the bar for anything that survives a pg_dump.

The Enrollment Flow

Enrollment has three steps: generate a fresh secret, show the user a QR code they scan with their authenticator app, and verify a first code before you actually enable 2FA. The last step matters — if you flip otp_enabled_at before the user proves they can produce a valid code, you have just locked out anyone whose phone camera failed to scan the QR.

# app/controllers/two_factor_setups_controller.rb
class TwoFactorSetupsController < ApplicationController
  before_action :authenticate_user!

  def new
    # Generate a fresh secret but do NOT persist otp_enabled_at yet.
    current_user.update!(otp_secret: ROTP::Base32.random) if current_user.otp_secret.blank?

    @totp = ROTP::TOTP.new(current_user.otp_secret, issuer: "TTB Software")
    @provisioning_uri = @totp.provisioning_uri(current_user.email)
    @qr_svg = RQRCode::QRCode.new(@provisioning_uri).as_svg(module_size: 4)
  end

  def create
    totp = ROTP::TOTP.new(current_user.otp_secret)

    if totp.verify(params[:code].to_s.strip, drift_behind: 30, drift_ahead: 30)
      current_user.update!(otp_enabled_at: Time.current)
      codes = generate_backup_codes(current_user)
      render :codes, locals: { codes: codes }
    else
      flash.now[:alert] = "That code did not match. Try the next one your app shows."
      render :new, status: :unprocessable_entity
    end
  end

  private

  def generate_backup_codes(user)
    user.backup_codes.destroy_all
    Array.new(10) do
      raw = SecureRandom.alphanumeric(10).downcase
      user.backup_codes.create!(code_digest: BCrypt::Password.create(raw))
      raw.scan(/.{5}/).join("-")
    end
  end
end

Three details worth calling out. First, drift_behind and drift_ahead of 30 seconds absorb clock skew on the user’s phone — anything tighter starts rejecting valid codes when the phone is 10 seconds off NTP. Second, backup codes are hashed with bcrypt, not stored plaintext; the raw codes are shown to the user exactly once, and if they lose them the recovery path is a support-ticket re-enrollment. Third, Base32.random from ROTP produces a 160-bit secret encoded in the format Google Authenticator expects — do not roll your own SecureRandom.hex here, the encoding will not match.

The Login Flow: Challenge, Verify, Remember

The login endpoint splits in two. Password verification stays where it is; on success, if the user has 2FA enabled, the session is only half-established and the user is redirected to an OTP challenge page. The challenge accepts either a TOTP code or a backup code.

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def create
    user = User.find_by(email: params[:email]&.downcase)
    if user&.authenticate(params[:password])
      if user.otp_enabled?
        session[:pending_2fa_user_id] = user.id
        session[:pending_2fa_at] = Time.current.to_i
        redirect_to new_two_factor_challenge_path
      else
        sign_in(user)
        redirect_to after_sign_in_path
      end
    else
      redirect_to new_session_path, alert: "Invalid email or password."
    end
  end
end

# app/controllers/two_factor_challenges_controller.rb
class TwoFactorChallengesController < ApplicationController
  before_action :require_pending_2fa

  def new; end

  def create
    if @pending_user.otp_locked?
      return redirect_to new_session_path,
                         alert: "Too many failed attempts. Try again in a few minutes."
    end

    if verify_totp(params[:code]) || consume_backup_code(params[:code])
      @pending_user.update!(failed_otp_attempts: 0, otp_last_used_at: Time.current)
      session.delete(:pending_2fa_user_id)
      session.delete(:pending_2fa_at)
      sign_in(@pending_user)
      remember_device! if params[:remember_device] == "1"
      redirect_to after_sign_in_path
    else
      register_failed_attempt!
      flash.now[:alert] = "Invalid code."
      render :new, status: :unprocessable_entity
    end
  end

  private

  def require_pending_2fa
    @pending_user = User.find_by(id: session[:pending_2fa_user_id])
    started_at = session[:pending_2fa_at].to_i
    if @pending_user.nil? || (Time.current.to_i - started_at) > 5.minutes
      session.delete(:pending_2fa_user_id)
      redirect_to new_session_path, alert: "Your login session expired. Please sign in again."
    end
  end

  def verify_totp(code)
    ROTP::TOTP.new(@pending_user.otp_secret).verify(
      code.to_s.strip,
      drift_behind: 30,
      drift_ahead: 30,
      after: @pending_user.otp_last_used_at
    )
  end

  def consume_backup_code(code)
    normalized = code.to_s.gsub(/[^a-z0-9]/i, "").downcase
    @pending_user.backup_codes.where(used_at: nil).find_each do |bc|
      if BCrypt::Password.new(bc.code_digest) == normalized
        bc.update!(used_at: Time.current)
        return true
      end
    end
    false
  end

  def register_failed_attempt!
    @pending_user.increment!(:failed_otp_attempts)
    if @pending_user.failed_otp_attempts >= 5
      @pending_user.update!(otp_locked_until: 15.minutes.from_now,
                            failed_otp_attempts: 0)
    end
  end
end

Two things this covers that most tutorials do not. The after: parameter on ROTP::TOTP#verify prevents the same 6-digit code from being replayed — critical if you have a shoulder-surfer or a network log with the code visible. And backup codes are consumed atomically with find_each plus a per-row update! on used_at, so a code that was already burned cannot be presented twice even under a concurrent request.

Remember-This-Device Without Weakening the Second Factor

Users hate typing a TOTP code on every login. The common shortcut — a cookie that skips 2FA entirely — quietly downgrades your Rails 2FA to no-2FA on any device the attacker can plant a cookie on. Do it right by binding the trust cookie to a specific user record with a rotating token and a hard expiry.

# db/migrate/20260821130000_create_trusted_devices.rb
create_table :trusted_devices do |t|
  t.references :user, null: false, foreign_key: true
  t.string     :token_digest, null: false
  t.string     :user_agent
  t.datetime   :expires_at, null: false
  t.datetime   :last_used_at
  t.timestamps
end
add_index :trusted_devices, :token_digest, unique: true

# In TwoFactorChallengesController
def remember_device!
  raw = SecureRandom.urlsafe_base64(32)
  @pending_user.trusted_devices.create!(
    token_digest: Digest::SHA256.hexdigest(raw),
    user_agent: request.user_agent&.first(255),
    expires_at: 30.days.from_now
  )
  cookies.encrypted[:trusted_device] = { value: raw, expires: 30.days.from_now, httponly: true, secure: true, same_site: :lax }
end

On the next login, if the cookie decrypts to a token whose SHA-256 exists in trusted_devices for that user and has not expired, skip the 2FA challenge. Revoke every trusted device when the user changes their password. Cap the number per user to something like 5 so an attacker who steals a session cannot silently enroll a hundred devices.

Account Recovery Without a Bypass

The recovery path is where every 2FA implementation dies. The user lost their phone, the backup codes were in the same iCloud they cannot log into, and now they want in. If your recovery flow is “email a link to disable 2FA”, congratulations — you have built account recovery, not 2FA. An attacker who compromises the email regains everything.

The recovery flow I install has three tiers:

  1. Backup codes first. Ten codes at enrollment, printed or stored in a password manager, single-use each. If the user has any left, they log in with one and are prompted to re-enroll.
  2. Support-assisted reset second. The user submits a form with proof-of-identity (last four of card, recent invoice number, whatever your product supports). A human on your team verifies and resets otp_enabled_at to nil. Log every reset — auditors will ask.
  3. Never an automatic email-only reset. If the account is high-value enough to warrant 2FA, the reset is high-value enough to warrant a human.

Testing Rails 2FA Without a Real Authenticator

Testing TOTP looks intimidating until you realize the algorithm is deterministic — you can compute the expected code from the secret and a fixed timestamp.

# spec/requests/two_factor_challenges_spec.rb
require "rails_helper"

RSpec.describe "Two-factor login", type: :request do
  let(:user) { create(:user, :with_2fa_enabled) }

  around do |example|
    Timecop.freeze(Time.utc(2026, 8, 21, 12, 0, 0)) { example.run }
  end

  it "signs in with a valid TOTP" do
    post session_path, params: { email: user.email, password: "correct-horse" }
    valid_code = ROTP::TOTP.new(user.otp_secret).now
    post two_factor_challenge_path, params: { code: valid_code }
    expect(response).to redirect_to(dashboard_path)
    expect(session[:user_id]).to eq(user.id)
  end

  it "rejects a replayed code" do
    valid_code = ROTP::TOTP.new(user.otp_secret).now
    user.update!(otp_last_used_at: Time.current)
    post session_path, params: { email: user.email, password: "correct-horse" }
    post two_factor_challenge_path, params: { code: valid_code }
    expect(response).to have_http_status(:unprocessable_entity)
  end
end

Two more integration tests worth writing: five failed attempts triggers the lock, and a backup code consumed once cannot be consumed again. Both are one-line assertions once the setup helpers exist, and both are the exact bugs that will bite you in production if you skip them.

Rolling Out Rails 2FA to Existing Users

Do not enforce 2FA on everyone the day you ship it. Ship enrollment as opt-in for two weeks, then flip the switch by user segment: admins first, paying customers second, everyone else last. Track adoption in a simple dashboard — enrollment rate, backup-code usage rate, failed-attempt lockouts per day. The number that will surprise you is the lockout rate; it is almost always a clock-skew issue on Android and disappears the moment you widen drift_ahead to 30 seconds.

Send the enrollment nudge from an authenticated in-app banner, not email — the whole point of Rails 2FA is to reduce your dependence on email as an authentication channel. And write the recovery instructions before you enforce, not after. Every SaaS I have ever helped through a 2FA rollout underestimated the support volume from users who bought a new phone and did not migrate their authenticator. Ten codes on an enrollment PDF cuts that ticket volume by 80%.

Frequently Asked Questions

Is TOTP still secure enough in 2026, or should I skip straight to passkeys?

Passkeys are cryptographically stronger and phishing-resistant, which TOTP is not. But TOTP is universally supported and works today for every user with any smartphone. My recommendation for most Rails apps in 2026: ship TOTP first because you can roll it out to 100% of users in a week, then offer passkeys as a stronger opt-in for users on modern devices. The two coexist happily — the second-factor decision at login time is “TOTP OR passkey OR backup code”.

What is the difference between the ROTP gem and devise-two-factor?

rotp is the underlying TOTP/HOTP implementation and has no framework opinions. devise-two-factor wraps rotp inside a Devise strategy with column conventions and encrypted-attribute helpers. If you already use Devise and want the shortest path, devise-two-factor will save you a day. If you want full control over enrollment, backup codes, and remembered devices — or you use Rails 8’s built-in authenticate — install rotp directly. The eighty lines of Ruby in this post are the entire delta.

How do I prevent a compromised database from becoming universal 2FA bypass?

Two layers. First, encrypt TOTP secrets at rest with ActiveRecord::Encryption so a pg_dump alone is not enough to generate valid codes. Second, hash backup codes with bcrypt so those cannot be replayed from a database dump either. An attacker who steals both your database and your Rails master key has bypassed 2FA — but that same attacker has your password hashes, session cookies, and encryption keys, and 2FA was never going to save you from a full-key compromise.

How do I handle the user who lost their phone and has no backup codes?

Do not build an automatic recovery path — it will be exploited. Build a manual one. A form that collects proof-of-identity, a queue that a support agent works through, a mandatory audit log entry, and a fresh enrollment flow after reset. Reserve automatic email-based bypass for accounts that have no meaningful data attached, and be honest with users that recovering a 2FA-protected account will take business hours. The alternative — a silent email-based bypass — turns your Rails 2FA into decorative security.

Need help hardening authentication in a production Rails app? TTB Software specializes in security-critical Rails work — 2FA, passkeys, SSO, audit logging, and the fractional-CTO oversight that keeps it that way. We have been shipping Rails in production for nineteen years.

#rails-2fa #rails-totp #rails-two-factor-authentication #rails-rotp-gem #rails-backup-codes #rails-authenticator-app

Related Articles

Last section. Then please call.

It's a phone call. That's the worst it can get.

No discovery deck. No 45-minute "qualification" call. 30 minutes, your problem, my opinion. If we're a fit, you'll know by minute 12.

Direct line — answered by Roger
+31 6 5123 6132
Mon–Fri, 09:00–18:00 CET · Currently available

OR
info@ttb.software