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