Two-Factor Authentication (TOTP) with Remember-This-Device
⚠️ Cookbook example — not live code. (KEEP THIS CALLOUT.) Every code block below is an example snippet, not part of the llamapress.ai codebase, and not running on this server. This is a reference recipe for a Leo instance (an AI coding agent) to implement in its own app — read it to understand the pattern, then recreate it there.
Your app already signs people in with Devise and a password. This recipe adds a second factor: a 6-digit code from an authenticator app (Google Authenticator, Authy, 1Password). A user scans a QR code once to enroll, then gets challenged for a code at sign-in. A successful challenge drops a signed, encrypted cookie that trusts the device for 14 days, so the prompt is a fortnightly speed bump rather than a daily tax.
It is a two-step flow: password first, code second, on its own screen. That is deliberate — it is what makes "remember this device" possible at all.
When to use: the app holds money, contracts, payroll, client records, or anything a customer's insurer or security reviewer will ask about. Also when a customer says "we need 2FA" — this is the whole answer. When not to: a single-user internal tool, or a prototype nobody has signed into yet. 2FA is easy to add later and annoying to debug in a demo.
The 80/20 in one breath
- Add three columns to
users:otp_secret,consumed_timestep,otp_required_for_login. - In
User,include Devise::Models::TwoFactorAuthenticatable— not thedevise :two_factor_authenticatableline (see Gotchas; this is the one that bites). - Confirm Active Record Encryption keys are configured. The gem encrypts
otp_secret, so with no keys you either crash on enrollment or wire up a secret you can lose. - Add a
Users::TwoFactorControllerwith four actions —setup/enable(one-time enrollment) andchallenge/verify(per-login) — plus four routes. - Add
before_action :enforce_two_factor!toApplicationController, exempting the Devise controllers and the 2FA controller itself or you get an infinite redirect. - Give admins a "Reset 2FA" button for lost phones. Without it, a lost device is a database console job.
Layer 1 — Model & SQL
Three columns. Nothing else in the schema changes.
# db/migrate/20260101000000_add_two_factor_to_users.rb
class AddTwoFactorToUsers < ActiveRecord::Migration[7.2]
def change
# The shared secret behind the QR code. devise-two-factor 6.x stores this
# ENCRYPTED via Active Record Encryption, so the stored value is much longer
# than the ~32-character raw secret. Use :text if your database caps varchar.
add_column :users, :otp_secret, :string
# The last 30-second timestep this user successfully consumed. Blocks replay:
# a code that just worked cannot be submitted a second time.
add_column :users, :consumed_timestep, :integer
# Enrollment finished. Separate from otp_secret being present, because a
# secret exists during enrollment before the user has proven they can read it.
add_column :users, :otp_required_for_login, :boolean, default: false, null: false
end
end
# app/models/user.rb
class User < ApplicationRecord
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
# Include the MODEL module directly rather than adding :two_factor_authenticatable
# to the `devise` line above. That line also installs the gem's single-step
# Warden strategy, which expects the OTP and the password in ONE form and would
# fight the two-step flow (and the remember-device cookie) below.
#
# The module gives us the encrypted :otp_secret attribute plus
# validate_and_consume_otp!, otp_provisioning_uri, generate_otp_secret, current_otp.
include Devise::Models::TwoFactorAuthenticatable
# Shown in the authenticator app, e.g. "Acme Estimating (alice@acme.com)".
OTP_ISSUER = "Acme Estimating".freeze
# How long a passed challenge is trusted on one device before we ask again.
OTP_REMEMBER_DURATION = 14.days
# True once the user has finished enrollment: they hold a secret AND proved it
# by entering a working code. Both halves matter — see the migration comment.
def two_factor_enabled?
otp_required_for_login? && otp_secret.present?
end
# Mint a secret for the enrollment screen. NOT enabled yet: the user must
# confirm a code first. save!(validate: false) so an unrelated validation
# failure elsewhere on the record can't block enrollment.
def reset_otp_secret!
self.otp_secret = self.class.generate_otp_secret
self.otp_required_for_login = false
self.consumed_timestep = nil
save!(validate: false)
otp_secret
end
# Finish enrollment: lock 2FA on for this account.
def enable_two_factor!
update!(otp_required_for_login: true)
end
# Admin lost-device recovery. Wiping otp_secret also revokes every remembered
# device, because the device cookie is fingerprinted against the secret.
def disable_two_factor!
update!(otp_required_for_login: false, otp_secret: nil, consumed_timestep: nil)
end
# The otpauth:// URI that becomes the enrollment QR code.
def otp_provisioning_uri_for_app
otp_provisioning_uri(email, issuer: OTP_ISSUER)
end
end
Layer 2 — The gate in ApplicationController
This is the security boundary. Everything else is user interface.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
before_action :authenticate_user!
before_action :enforce_two_factor! # order matters — after Devise establishes a session
private
# Runs on every authenticated browser request. Enrolled users must pass a
# challenge once per remembered device; users who haven't enrolled are pushed
# into enrollment first, so 2FA is mandatory rather than opt-in.
def enforce_two_factor!
return if api_request? # token clients have no browser to challenge
return unless user_signed_in?
return if impersonating? # don't trap an admin behind a user's phone
return if two_factor_exempt_controller?
if current_user.two_factor_enabled?
return if two_factor_passed?
store_two_factor_return_path
redirect_to challenge_two_factor_path
else
redirect_to setup_two_factor_path,
notice: "For security, please set up two-factor authentication to continue."
end
end
# THE REDIRECT-LOOP GUARD. The 2FA screens and the Devise screens must stay
# reachable without a satisfied second factor — otherwise the redirect target
# is itself gated and the browser bounces forever.
def two_factor_exempt_controller?
devise_controller? || controller_path == "users/two_factor"
end
def impersonating?
session[:admin_id].present?
end
# Has this session already cleared 2FA — this browser session, or via a still
# valid device cookie? A valid cookie promotes itself to a session flag so we
# only pay the decrypt + fingerprint check once per session.
def two_factor_passed?
return true if session[:two_factor_verified_user_id] == current_user.id
return false unless two_factor_device_remembered?
session[:two_factor_verified_user_id] = current_user.id
true
end
# Record success: trust this session, and drop a 14-day signed+encrypted cookie
# so we don't ask again until it expires.
def mark_two_factor_passed!(user)
session[:two_factor_verified_user_id] = user.id
cookies.encrypted[:tfa_device] = {
value: {
"uid" => user.id,
"iat" => Time.current.to_i,
"fp" => two_factor_device_fingerprint(user)
}.to_json,
expires: User::OTP_REMEMBER_DURATION.from_now,
httponly: true,
same_site: :lax,
secure: Rails.env.production?
}
end
def two_factor_device_remembered?
raw = cookies.encrypted[:tfa_device]
return false if raw.blank?
data = JSON.parse(raw) rescue nil
return false unless data.is_a?(Hash)
return false unless data["uid"] == current_user.id
return false unless data["fp"] == two_factor_device_fingerprint(current_user)
Time.zone.at(data["iat"].to_i) > User::OTP_REMEMBER_DURATION.ago
rescue StandardError
false # any malformed/undecryptable cookie means "not remembered", never a 500
end
# Binds the cookie to the account's CURRENT secret, so an admin "reset 2FA"
# (which clears otp_secret) instantly revokes every remembered device.
def two_factor_device_fingerprint(user)
Digest::SHA256.hexdigest("#{user.id}:#{user.otp_secret}")[0, 32]
end
# Remember where they were heading so #verify can return them there.
# GET only — never stash a form POST target as a redirect destination.
def store_two_factor_return_path
return unless request.get?
session[:two_factor_return_to] = request.fullpath
end
end
Layer 3 — The 2FA controller
Four actions, two pairs. setup/enable run once per user; challenge/verify run at
sign-in. Both pairs need the same duplicate-submit guard.
# app/controllers/users/two_factor_controller.rb
# frozen_string_literal: true
# The user is already signed in with a password by the time they reach here.
# This controller is exempt from #enforce_two_factor! so it cannot redirect-loop.
class Users::TwoFactorController < ApplicationController
# GET /users/two_factor/setup — show the QR to enroll an authenticator.
def setup
if current_user.two_factor_enabled?
redirect_to(two_factor_passed? ? after_two_factor_path : challenge_two_factor_path)
return
end
# Keep a STABLE pending secret across page refreshes. Minting a new secret on
# every render would silently invalidate a QR the user already scanned.
current_user.reset_otp_secret! if current_user.otp_secret.blank?
assign_enrollment_view_data
end
# POST /users/two_factor/enable — confirm the first code and lock 2FA on.
def enable
# Duplicate submit: the first POST enabled 2FA and consumed the code's
# timestep. Re-validating the same code would fail and show a bogus error to
# an already-enrolled user. Pass them through instead.
if current_user.two_factor_enabled? && two_factor_passed?
return redirect_to after_two_factor_path, notice: "Two-factor authentication is now enabled."
end
if current_user.otp_secret.present? && current_user.validate_and_consume_otp!(otp_attempt)
current_user.enable_two_factor!
mark_two_factor_passed!(current_user)
redirect_to after_two_factor_path, notice: "Two-factor authentication is now enabled."
else
flash.now[:alert] = "That code wasn't right. Make sure your device's clock " \
"is correct and try the current 6-digit code."
assign_enrollment_view_data
render :setup, status: :unprocessable_entity
end
end
# GET /users/two_factor/challenge — prompt an enrolled user for a code.
def challenge
return redirect_to(setup_two_factor_path) unless current_user.two_factor_enabled?
return redirect_to(after_two_factor_path) if two_factor_passed?
end
# POST /users/two_factor/verify — verify the code, then trust the device.
def verify
return redirect_to(setup_two_factor_path) unless current_user.two_factor_enabled?
# Same duplicate-submit guard as #enable.
if two_factor_passed?
return redirect_to(session.delete(:two_factor_return_to) || after_two_factor_path)
end
if current_user.validate_and_consume_otp!(otp_attempt)
mark_two_factor_passed!(current_user)
redirect_to(session.delete(:two_factor_return_to) || after_two_factor_path)
else
flash.now[:alert] = "Incorrect code. Please enter the current 6-digit code " \
"from your authenticator app."
render :challenge, status: :unprocessable_entity
end
end
private
def otp_attempt
params[:otp_attempt].to_s.strip
end
def assign_enrollment_view_data
uri = current_user.otp_provisioning_uri_for_app
@provisioning_uri = uri
@manual_key = current_user.otp_secret
@qr_svg = RQRCode::QRCode.new(uri).as_svg(
module_size: 5, standalone: true, use_path: true, viewbox: true
).html_safe
end
def after_two_factor_path
after_sign_in_path_for(current_user)
end
end
# config/routes.rb
controller "users/two_factor" do
get "users/two_factor/setup", action: :setup, as: :setup_two_factor
post "users/two_factor/enable", action: :enable, as: :enable_two_factor
get "users/two_factor/challenge", action: :challenge, as: :challenge_two_factor
post "users/two_factor/verify", action: :verify, as: :verify_two_factor
end
# A user who bookmarks or back-buttons onto the POST-only enable URL would
# otherwise get a routing error. Send the GET to the form instead.
get "users/two_factor/enable" => redirect("users/two_factor/setup")
Layer 4 — The views
Both screens are the same shape: a card, an alert slot, a 6-digit input, one button. The details that matter are on the form tag and the input.
<%# app/views/users/two_factor/challenge.html.erb %>
<div class="min-h-[80vh] flex items-center justify-center px-4">
<div class="max-w-md w-full">
<h1 class="text-2xl font-bold text-gray-900 text-center mb-1">Two-factor verification</h1>
<p class="text-sm text-gray-500 text-center mb-8">Enter the code from your authenticator app</p>
<div class="bg-white rounded-xl shadow-lg border border-gray-200 p-8">
<% if flash[:alert].present? %>
<div class="mb-4 p-3 bg-red-50 border border-red-200 text-red-700 rounded-lg text-sm">
<%= flash[:alert] %>
</div>
<% end %>
<%# Plain non-Turbo form. Nothing disables the button natively and the
post-verify redirect can be slow, so a second click re-POSTs a code
that has already been consumed and shows "Incorrect code" to a user
who is in fact fully verified. Guard on BOTH ends: here in the browser,
and with the two_factor_passed? early return in the controller. %>
<%= form_with url: verify_two_factor_path, method: :post, data: { turbo: false },
html: { onsubmit: "if (this.dataset.submitted) return false; this.dataset.submitted = '1'; var b = this.querySelector('[type=submit]'); b.disabled = true; b.value = 'Verifying…';" } do %>
<div class="space-y-5">
<%= label_tag :otp_attempt, "6-digit code", class: "block text-sm font-semibold text-gray-700 mb-1.5" %>
<%# autocomplete="one-time-code" makes iOS/Android offer the SMS or
authenticator code straight from the keyboard. inputmode="numeric"
gives a number pad instead of a full QWERTY. %>
<%= text_field_tag :otp_attempt, nil,
autofocus: true, autocomplete: "one-time-code", inputmode: "numeric",
pattern: "[0-9]*", maxlength: 6, placeholder: "123456",
class: "w-full border border-gray-300 rounded-lg px-4 py-2.5 text-center tracking-[0.5em] text-lg" %>
<p class="text-xs text-gray-500">This device will be remembered for 14 days.</p>
<%= submit_tag "Verify", class: "w-full bg-blue-600 hover:bg-blue-700 text-white font-bold py-2.5 rounded-lg cursor-pointer text-sm" %>
</div>
<% end %>
<%# ALWAYS give them a way out. Without this, a user whose phone is dead is
stuck on a page with no navigation and no way to reach support. %>
<div class="mt-6 pt-6 border-t border-gray-100 text-center">
<%= button_to "Sign out", destroy_user_session_path, method: :delete,
class: "text-sm text-gray-500 hover:text-gray-700", form: { data: { turbo: false } } %>
</div>
</div>
</div>
</div>
The enrollment screen is the same form pointed at enable_two_factor_path, plus the QR
and a copyable fallback key:
<%# app/views/users/two_factor/setup.html.erb — the enrollment-only parts %>
<ol class="text-sm text-gray-600 space-y-1 mb-5 list-decimal list-inside">
<li>Install an authenticator app (Google Authenticator, Authy, 1Password…).</li>
<li>Scan the QR code below, or enter the key manually.</li>
<li>Enter the 6-digit code it shows to finish.</li>
</ol>
<div class="flex justify-center mb-4">
<div class="p-3 bg-white border border-gray-200 rounded-lg w-48 h-48 flex items-center justify-center">
<%= @qr_svg %>
</div>
</div>
<%# Desktop users and locked-down phones often cannot scan. select-all makes the
key one click to copy. Never ship the QR without this fallback. %>
<div class="mb-6 text-center">
<p class="text-xs text-gray-500 mb-1">Can't scan? Enter this key manually:</p>
<code class="text-sm font-mono bg-gray-100 px-2 py-1 rounded break-all select-all"><%= @manual_key %></code>
</div>
Layer 5 — Admin reset (lost device)
Ship this on day one. People lose phones, and the alternative is a console.
# app/controllers/admin/users_controller.rb
# Clears the user's enrollment so they set 2FA up again at next sign-in. Because
# the remember-device cookie is fingerprinted against otp_secret, this ALSO
# revokes every device they had previously remembered.
def reset_two_factor
user = User.find(params[:id])
user.disable_two_factor!
redirect_to admin_users_path,
notice: "Two-factor authentication reset for #{user.email}. They'll set it up again at next sign-in."
rescue => e
redirect_to admin_users_path, alert: "Failed to reset two-factor: #{e.message}"
end
# config/routes.rb
namespace :admin do
resources :users do
member { post :reset_two_factor }
end
end
Gotchas (the hard-won stuff)
Do not put
:two_factor_authenticatableon thedeviseline. It installs the gem's single-step Warden strategy, which expects the password and the OTP in one form. That silently fights every part of this recipe — the separate challenge screen, the return-path handling, and remember-this-device.include Devise::Models::TwoFactorAuthenticatablegives you the model behaviour with no strategy attached. This is the single most expensive mistake in the pattern.otp_secretis encrypted, so Active Record Encryption keys are load-bearing. devise-two-factor 6.x encrypts the column. On a Leo box the base image shipsconfig/initializers/leonardo_two_factor.rb, which readsACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY/_DETERMINISTIC_KEY/_KEY_DERIVATION_SALTfrom the environment and — in non-production only — falls back to keys derived fromsecret_key_base. Leo boxes run withRAILS_ENV=development, so that fallback is usually what is protecting your secrets. Consequence: ifsecret_key_baseever changes, every enrolled secret becomes undecryptable and every user is locked out. For anything real, set the threeACTIVE_RECORD_ENCRYPTION_*variables in.envexplicitly and treat them as backup-critical. Check before you enroll anyone:docker compose exec -T llamapress bin/rails runner \ 'puts ActiveRecord::Encryption.config.primary_key.present? ? "keys configured" : "NO KEYS"'Remember that
.envchanges needdocker compose down && docker compose up -d— a plainrestartdoes not reload the environment.Exempt the Devise controllers AND the 2FA controller, or the browser loops forever.
enforce_two_factor!redirects to a page that is itself behindenforce_two_factor!unless you carve both out. This producesERR_TOO_MANY_REDIRECTSwith nothing useful in the logs.A consumed code cannot be reused, so a double-click looks like a wrong code.
validate_and_consume_otp!burns the 30-second timestep. The first POST succeeds; the second POST of the same code fails and the user sees "Incorrect code" after already being verified. Guard on both ends: an earlytwo_factor_passed?return in the controller, anddata: { turbo: false }plus anonsubmitthat disables the button. Turbo's default form handling gives no native disabled state here.Do not mint a new secret on every render of the setup page. A refresh after the user has scanned would rotate the secret out from under their authenticator, and every code they enter is then wrong. Mint only when
otp_secretis blank, and usesave!(validate: false)so an unrelated validation error on the user record cannot block enrollment.Bind the remember-device cookie to the current
otp_secret. Without the fingerprint, an admin "reset 2FA" leaves every previously-trusted browser trusted — which is exactly backwards for the lost-phone case the reset exists to handle.Only stash a GET path as the post-verify return target. Stashing a POST URL sends the user to a route that rejects GET after they verify.
Exempt API/token requests and admin impersonation. A Bearer-token client has no browser to challenge, and an admin impersonating a user cannot produce that user's phone. Both otherwise become hard lockouts.
The gems are already in the base image —
devise-two-factor6.4,rqrcode3.2 androtp6.3 are inGemfile.lock. You do not need an image rebuild for this recipe. Confirm before you start:docker compose exec -T llamapress grep -E 'devise-two-factor|rqrcode|rotp' /rails/Gemfile.lockRQRCode 3.x renders SVG, and you must mark it
html_safe.as_svg(module_size: 5, standalone: true, use_path: true, viewbox: true)gives a crisp, scalable code that needs no image pipeline. Withoutviewboxit will not scale inside a fixed-size box.Clock skew is the #1 support ticket, not a bug. TOTP compares the user's device clock to the server's. Say so in the error message ("make sure your device's clock is correct") and you will answer most of these before they are sent.
Set
secure: Rails.env.production?rather than a baretrue. A hardcodedtruemeans the cookie is never stored over plain HTTP, so local development silently re-challenges on every request and looks like a broken cookie.
Files this pattern touches
db/migrate/20260101000000_add_two_factor_to_users.rb
app/models/user.rb
app/controllers/application_controller.rb
app/controllers/users/two_factor_controller.rb
app/controllers/admin/users_controller.rb
app/views/users/two_factor/setup.html.erb
app/views/users/two_factor/challenge.html.erb
config/routes.rb
How to adapt to your schema
- Rename the issuer.
OTP_ISSUERis the label users see in their authenticator app next to the account. Use the product name, not the class name. - Change the trust window.
OTP_REMEMBER_DURATIONis the only knob for how often users are challenged. 14 days is a comfortable default; drop to1.dayfor high-sensitivity apps, or deletetwo_factor_device_remembered?entirely to challenge every session. - Make it opt-in instead of mandatory. In
enforce_two_factor!, replace theelsebranch (which pushes unenrolled users into setup) with a plainreturn. Users then only see 2FA if they visit/users/two_factor/setupthemselves. Add a link on the profile page. - Gate it by role. Wrap the enforcement in
return unless current_user.admin?to require a second factor only from privileged accounts — a common middle ground when a customer wants 2FA "for the office, not the field crew". - Different auth stack? The only Devise-specific pieces are
user_signed_in?,current_user,devise_controller?andafter_sign_in_path_for. The secret handling, the cookie and the controller flow are plain Rails. - Safe to drop for small apps: the admin reset (recover from the console instead), the impersonation exemption (if you have no impersonation), and the return-path handling (send everyone to the root path after verifying).