---
title: Two-Factor Authentication (TOTP) with Remember-This-Device
slug: two-factor-authentication-totp
category: Auth
summary: 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.
tags: [auth, devise, 2fa, totp, otp, security, qr, cookies]
status: stable
visibility: public
source_project: lohman.llamapress.ai
layers: [model, controller, view, sql]
related:
  - title: Rate Limiting, IP Allowlists & a Request Audit Trail
    url: /cookbook/rate-limiting-and-ip-controls
    summary: The sibling security recipe — throttle the login endpoint this guide protects, and log who signed in from where.
  - title: Sign in with LlamaPress (SSO)
    url: /cookbook/sign-in-with-llamapress-sso
    summary: The other authentication recipe — federated sign-in instead of a second factor on local accounts.
  - title: devise-two-factor
    url: https://github.com/devise-two-factor/devise-two-factor
    summary: Upstream gem docs for the model module, validate_and_consume_otp! and provisioning URIs.
---

# 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::TwoFactorAuthenticatable` — **not** 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.

```ruby
# 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
```

```ruby
# 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.

```ruby
# 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.

```ruby
# 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
```

```ruby
# 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.

```erb
<%# 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:

```erb
<%# 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.

```ruby
# 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
```

```ruby
# 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:

  ```bash
  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 image** — `devise-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).
