Two-Factor Authentication (TOTP) in Rails | LlamaPress Cookbook
LlamaPress
Cookbook
Auth Stable

Two-Factor Authentication (TOTP) with Remember-This-Device

A two-step TOTP second factor on top of Devise — QR enrollment, a per-login code challenge, a 14-day signed "remember this device" cookie, and an admin lost-device reset. Uses devise-two-factor + rqrcode, both already in the base image.

Proven on lohman.llamapress.ai model · controller · view · sql

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

  1. Add three columns to users: otp_secret, consumed_timestep, otp_required_for_login.
  2. In User, include Devise::Models::TwoFactorAuthenticatablenot the devise :two_factor_authenticatable line (see Gotchas; this is the one that bites).
  3. 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.
  4. Add a Users::TwoFactorController with four actions — setup / enable (one-time enrollment) and challenge / verify (per-login) — plus four routes.
  5. Add before_action :enforce_two_factor! to ApplicationController, exempting the Devise controllers and the 2FA controller itself or you get an infinite redirect.
  6. 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_authenticatable on the devise line. 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::TwoFactorAuthenticatable gives you the model behaviour with no strategy attached. This is the single most expensive mistake in the pattern.

  • otp_secret is encrypted, so Active Record Encryption keys are load-bearing. devise-two-factor 6.x encrypts the column. On a Leo box the base image ships config/initializers/leonardo_two_factor.rb, which reads ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY / _DETERMINISTIC_KEY / _KEY_DERIVATION_SALT from the environment and — in non-production only — falls back to keys derived from secret_key_base. Leo boxes run with RAILS_ENV=development, so that fallback is usually what is protecting your secrets. Consequence: if secret_key_base ever changes, every enrolled secret becomes undecryptable and every user is locked out. For anything real, set the three ACTIVE_RECORD_ENCRYPTION_* variables in .env explicitly 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 .env changes need docker compose down && docker compose up -d — a plain restart does 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 behind enforce_two_factor! unless you carve both out. This produces ERR_TOO_MANY_REDIRECTS with 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 early two_factor_passed? return in the controller, and data: { turbo: false } plus an onsubmit that 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_secret is blank, and use save!(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 imagedevise-two-factor 6.4, rqrcode 3.2 and rotp 6.3 are in Gemfile.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.lock

  • RQRCode 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. Without viewbox it 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 bare true. A hardcoded true means 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

  1. Rename the issuer. OTP_ISSUER is the label users see in their authenticator app next to the account. Use the product name, not the class name.
  2. Change the trust window. OTP_REMEMBER_DURATION is the only knob for how often users are challenged. 14 days is a comfortable default; drop to 1.day for high-sensitivity apps, or delete two_factor_device_remembered? entirely to challenge every session.
  3. Make it opt-in instead of mandatory. In enforce_two_factor!, replace the else branch (which pushes unenrolled users into setup) with a plain return. Users then only see 2FA if they visit /users/two_factor/setup themselves. Add a link on the profile page.
  4. 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".
  5. Different auth stack? The only Devise-specific pieces are user_signed_in?, current_user, devise_controller? and after_sign_in_path_for. The secret handling, the cookie and the controller flow are plain Rails.
  6. 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).

Related