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.
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:
- 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.
- 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_atto nil. Log every reset — auditors will ask. - 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.
Related Articles
Rails Idempotency Keys: Safe API Retries, Stripe-Style, for Payments and Webhooks
Rails idempotency keys: make POST endpoints safe to retry. Stripe-style middleware, Postgres storage, TTL, race-condi...
Rails Rack::Attack: Production Rate Limiting, IP Throttling, and Blocking Abuse
Rails Rack::Attack guide: production rate limiting, IP throttling, fail2ban, and blocking abusive traffic. Real confi...
Rails pgvector: Semantic Search and RAG with PostgreSQL for LLM Applications
Rails pgvector guide: build semantic search and RAG on PostgreSQL with embeddings, HNSW indexes, and hybrid retrieval...