RUBY ON RAILS · 20 MIN READ ·

Rails 2FA (TOTP): Twee-factor-authenticatie met ROTP, back-upcodes en versleutelde secrets

Rails 2FA met TOTP: implementeer twee-factor-authenticatie met ROTP, genereer back-upcodes, versleutel secrets met ActiveRecord en beperk herstelpogingen.

Rails 2FA (TOTP): Twee-factor-authenticatie met ROTP, back-upcodes en versleutelde secrets

Een SaaS-oprichter belde me vorig oktober in precies dat soort paniek dat alleen een credential-stuffing-golf oplevert. Aanvallers spuiten een oude breach-dump tegen zijn loginformulier, raken echte accounts, en veranderden onzichtbaar bankrekeningen op klantprofielen. Wachtwoorden waren lang, hashing was bcrypt, rate limiting stond aan. Niets hielp — de credentials waren écht geldig, alleen ergens anders gestolen. De enige oplossing die werkte was de volgende ochtend Rails 2FA aanzetten en enrollment forceren voor elk account met adminrechten. De credential-spray bleef drie weken doorgaan, en niet één aanvaller kwam voorbij de tweede factor.

Na negentien jaar Rails in productie is het patroon vervelend consistent: wachtwoorden lekken uiteindelijk, gebruikers hergebruiken ze, en de enige serieuze verdediging aan de loginrand is een tweede factor. Deze post is precies de Rails 2FA-implementatie die ik installeer met TOTP (Time-based One-Time Passwords) — de codes die je afleest uit Google Authenticator, 1Password of Authy — met de ROTP-gem, versleutelde secrets, back-upcodes en de rate-limiting die brute-forcen van de zescijferige challenge tegenhoudt.

Passkeys zijn strikt beter als je gebruikers ze adopteren, en dat schreef ik in Rails Passkeys met WebAuthn. Maar TOTP wint nog steeds op universaliteit: elke telefoon heeft een authenticator-app, elke enterprise-SSO-tool ondersteunt het, elk compliance-framework accepteert het. Rails 2FA met TOTP is de pragmatische default als je vandaag één factor bovenop een wachtwoord nodig hebt.

Wat Rails 2FA met TOTP eigenlijk is

TOTP is gedefinieerd in RFC 6238. Server en client delen bij enrollment een willekeurige secret. Elke 30 seconden draaien beide kanten HMAC-SHA1 over (secret, floor(unix_time / 30)), truncaten het resultaat tot zes cijfers, en vergelijken. Als de nummers matchen, heeft de gebruiker aangetoond dat hij een apparaat bezit met diezelfde secret. Meer is het niet — geen netwerkcalls bij verificatie, geen push-notificaties, geen SMS.

De reden dat Rails 2FA met TOTP houdbaar is, is dat de gedeelde secret na enrollment de twee endpoints nooit meer verlaat. Er is geen SMS om te onderscheppen, geen push-notificatiedienst die gecompromitteerd kan raken, geen OAuth-relatie met een derde partij. De faalscenario’s zijn beperkt: de gebruiker verliest zijn telefoon, de serverdatabase lekt, of iemand kijkt over de schouder mee binnen het 30-secondenvenster. Alle drie hebben specifieke mitigaties die ik hieronder laat zien.

Drie dingen die deze post aanneemt dat je al hebt: een werkende authenticatieflow (Devise, authenticate in Rails 8, of je eigen versie), overal HTTPS, en de rate limiting van Rack::Attack op je login-endpoints. Ontbreekt één daarvan, los dat eerst op — 2FA bovenop een niet-rate-limited loginformulier is theater.

Bibliotheekkeuze: ROTP, niet devise-two-factor

Het Ruby-ecosysteem heeft twee serieuze opties. De rotp-gem is een pure implementatie van RFC 6238 zonder frameworkmeningen — hij genereert secrets, maakt QR-code-URI’s, en verifieert codes. De devise-two-factor-gem wikkelt rotp in een Devise-strategie met kolommen en versleutelde-secretafhandeling ingebakken.

Op vrijwel elk project installeer ik rotp direct. De reden is controle: devise-two-factor koppelt je 2FA-levenscyclus aan die van Devise-wachtwoorden op manieren die het lastig maken om back-upcodes, remember-this-device-tokens of admin-forced re-enrollment toe te voegen. Negentig regels Ruby op de rotp-primitieven geven je het volledige gedrag zonder de aannames.

# Gemfile
gem "rotp", "~> 6.3"
gem "rqrcode", "~> 3.0"  # alleen nodig voor de enrollment-QR

rqrcode is optioneel — je kunt de QR ook in de browser renderen met een JS-library — maar de SVG serverside genereren houdt de secret buiten de JavaScript-context van de browser, wat de ~2 KB payload wat mij betreft waard is.

Het databaseschema voor Rails 2FA

Sla de secret en de enrollmentstatus op de user op, en zet back-upcodes in een aparte tabel zodat je ze afzonderlijk kunt invalideren. Sla nooit de plaintext TOTP-secret op; versleutel hem met ActiveRecord::Encryption zodat een gestolen databasedump niet meteen gelijkstaat aan universele 2FA-bypass.

# db/migrate/20260821120000_add_two_factor_to_users.rb
class AddTwoFactorToUsers < ActiveRecord::Migration[8.0]
  def change
    change_table :users do |t|
      t.string   :otp_secret_ciphertext
      t.datetime :otp_enabled_at
      t.datetime :otp_last_used_at
      t.integer  :failed_otp_attempts, null: false, default: 0
      t.datetime :otp_locked_until
    end

    create_table :backup_codes do |t|
      t.references :user, null: false, foreign_key: true
      t.string     :code_digest, null: false
      t.datetime   :used_at
      t.timestamps
    end

    add_index :backup_codes, [:user_id, :code_digest], unique: true
  end
end

De otp_secret staat op User als versleuteld attribuut:

# app/models/user.rb
class User < ApplicationRecord
  encrypts :otp_secret

  has_many :backup_codes, dependent: :destroy

  def otp_enabled?
    otp_enabled_at.present?
  end

  def otp_locked?
    otp_locked_until.present? && otp_locked_until.future?
  end
end

ActiveRecord::Encryption gebruikt je keyring uit config/credentials.yml.enc, wat betekent dat sleutelrotatie mogelijk is zonder elke rij opnieuw te versleutelen — zie de Rails credentials-gids voor het mechanisme. In de praktijk: versleutelde secrets, deterministische back-ups, geen plaintext op disk. Dat is de lat voor alles wat een pg_dump overleeft.

De enrollmentflow

Enrollment heeft drie stappen: genereer een verse secret, laat de gebruiker een QR-code zien die hij scant met zijn authenticator-app, en verifieer een eerste code voordat je 2FA daadwerkelijk aanzet. Die laatste stap is cruciaal — zet je otp_enabled_at vóórdat de gebruiker bewijst dat hij een geldige code kan produceren, dan sluit je iedereen buiten wiens telefooncamera de QR niet scande.

# app/controllers/two_factor_setups_controller.rb
class TwoFactorSetupsController < ApplicationController
  before_action :authenticate_user!

  def new
    # Nieuwe secret genereren, maar otp_enabled_at nog NIET persisteren.
    current_user.update!(otp_secret: ROTP::Base32.random) if current_user.otp_secret.blank?

    @totp = ROTP::TOTP.new(current_user.otp_secret, issuer: "TTB Software")
    @provisioning_uri = @totp.provisioning_uri(current_user.email)
    @qr_svg = RQRCode::QRCode.new(@provisioning_uri).as_svg(module_size: 4)
  end

  def create
    totp = ROTP::TOTP.new(current_user.otp_secret)

    if totp.verify(params[:code].to_s.strip, drift_behind: 30, drift_ahead: 30)
      current_user.update!(otp_enabled_at: Time.current)
      codes = generate_backup_codes(current_user)
      render :codes, locals: { codes: codes }
    else
      flash.now[:alert] = "Die code klopt niet. Probeer de volgende die je app toont."
      render :new, status: :unprocessable_entity
    end
  end

  private

  def generate_backup_codes(user)
    user.backup_codes.destroy_all
    Array.new(10) do
      raw = SecureRandom.alphanumeric(10).downcase
      user.backup_codes.create!(code_digest: BCrypt::Password.create(raw))
      raw.scan(/.{5}/).join("-")
    end
  end
end

Drie details die het benoemen waard zijn. Eerst: drift_behind en drift_ahead van 30 seconden absorberen klokafwijking op de telefoon van de gebruiker — strakker en je begint geldige codes af te wijzen wanneer de telefoon 10 seconden uit NTP loopt. Tweede: back-upcodes worden gehasht met bcrypt, niet plaintext opgeslagen; de ruwe codes worden precies één keer aan de gebruiker getoond, en verliest hij ze dan is het herstelpad een support-ticket-re-enrollment. Derde: Base32.random van ROTP produceert een 160-bits secret in het formaat dat Google Authenticator verwacht — verzin hier geen eigen SecureRandom.hex, de encoding zal niet matchen.

De loginflow: challenge, verifiëren, onthouden

Het login-endpoint splitst in tweeën. Wachtwoordverificatie blijft waar hij is; bij succes, als de gebruiker 2FA aan heeft staan, wordt de sessie slechts half opgezet en gaat de gebruiker naar een OTP-challengepagina. De challenge accepteert of een TOTP-code of een back-upcode.

# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
  def create
    user = User.find_by(email: params[:email]&.downcase)
    if user&.authenticate(params[:password])
      if user.otp_enabled?
        session[:pending_2fa_user_id] = user.id
        session[:pending_2fa_at] = Time.current.to_i
        redirect_to new_two_factor_challenge_path
      else
        sign_in(user)
        redirect_to after_sign_in_path
      end
    else
      redirect_to new_session_path, alert: "Ongeldig e-mailadres of wachtwoord."
    end
  end
end

# app/controllers/two_factor_challenges_controller.rb
class TwoFactorChallengesController < ApplicationController
  before_action :require_pending_2fa

  def new; end

  def create
    if @pending_user.otp_locked?
      return redirect_to new_session_path,
                         alert: "Te veel mislukte pogingen. Probeer het over een paar minuten opnieuw."
    end

    if verify_totp(params[:code]) || consume_backup_code(params[:code])
      @pending_user.update!(failed_otp_attempts: 0, otp_last_used_at: Time.current)
      session.delete(:pending_2fa_user_id)
      session.delete(:pending_2fa_at)
      sign_in(@pending_user)
      remember_device! if params[:remember_device] == "1"
      redirect_to after_sign_in_path
    else
      register_failed_attempt!
      flash.now[:alert] = "Ongeldige code."
      render :new, status: :unprocessable_entity
    end
  end

  private

  def require_pending_2fa
    @pending_user = User.find_by(id: session[:pending_2fa_user_id])
    started_at = session[:pending_2fa_at].to_i
    if @pending_user.nil? || (Time.current.to_i - started_at) > 5.minutes
      session.delete(:pending_2fa_user_id)
      redirect_to new_session_path, alert: "Je loginsessie is verlopen. Log opnieuw in."
    end
  end

  def verify_totp(code)
    ROTP::TOTP.new(@pending_user.otp_secret).verify(
      code.to_s.strip,
      drift_behind: 30,
      drift_ahead: 30,
      after: @pending_user.otp_last_used_at
    )
  end

  def consume_backup_code(code)
    normalized = code.to_s.gsub(/[^a-z0-9]/i, "").downcase
    @pending_user.backup_codes.where(used_at: nil).find_each do |bc|
      if BCrypt::Password.new(bc.code_digest) == normalized
        bc.update!(used_at: Time.current)
        return true
      end
    end
    false
  end

  def register_failed_attempt!
    @pending_user.increment!(:failed_otp_attempts)
    if @pending_user.failed_otp_attempts >= 5
      @pending_user.update!(otp_locked_until: 15.minutes.from_now,
                            failed_otp_attempts: 0)
    end
  end
end

Twee dingen die de meeste tutorials laten liggen. De after:-parameter op ROTP::TOTP#verify voorkomt dat dezelfde zescijferige code opnieuw wordt afgespeeld — cruciaal als er een schoudermeekijker is of een netwerklog waar de code zichtbaar in staat. En back-upcodes worden atomair verbruikt met find_each plus een update! op used_at per rij, zodat een code die al is opgebrand niet twee keer aangeboden kan worden, zelfs niet onder gelijktijdige requests.

Remember-this-device zonder de tweede factor te verzwakken

Gebruikers haten het om bij elke login een TOTP-code te typen. De veelgekozen sluiproute — een cookie die 2FA volledig overslaat — degradeert je Rails 2FA stilletjes tot geen-2FA op elk apparaat waar de aanvaller een cookie op kan planten. Doe het goed door de trust-cookie te binden aan een specifieke user-record met een roterend token en een harde verloopdatum.

# db/migrate/20260821130000_create_trusted_devices.rb
create_table :trusted_devices do |t|
  t.references :user, null: false, foreign_key: true
  t.string     :token_digest, null: false
  t.string     :user_agent
  t.datetime   :expires_at, null: false
  t.datetime   :last_used_at
  t.timestamps
end
add_index :trusted_devices, :token_digest, unique: true

# In TwoFactorChallengesController
def remember_device!
  raw = SecureRandom.urlsafe_base64(32)
  @pending_user.trusted_devices.create!(
    token_digest: Digest::SHA256.hexdigest(raw),
    user_agent: request.user_agent&.first(255),
    expires_at: 30.days.from_now
  )
  cookies.encrypted[:trusted_device] = { value: raw, expires: 30.days.from_now, httponly: true, secure: true, same_site: :lax }
end

Bij de volgende login: als de cookie decrypteert tot een token waarvan de SHA-256 voorkomt in trusted_devices voor die user en nog niet is verlopen, sla je de 2FA-challenge over. Trek alle trusted devices in wanneer de gebruiker zijn wachtwoord wijzigt. Cap het aantal per user op iets als 5 zodat een aanvaller die een sessie steelt niet stilletjes honderd apparaten kan aanmelden.

Accountherstel zonder bypass

Het herstelpad is de plek waar elke 2FA-implementatie sterft. De gebruiker heeft zijn telefoon verloren, de back-upcodes stonden in dezelfde iCloud waar hij niet in kan, en nu wil hij binnen. Als je herstelflow “e-mail een link om 2FA uit te zetten” is, gefeliciteerd — je hebt account recovery gebouwd, geen 2FA. Een aanvaller die de e-mail compromitteert krijgt alles terug.

Het herstelpad dat ik installeer heeft drie lagen:

  1. Back-upcodes eerst. Tien codes bij enrollment, geprint of opgeslagen in een password manager, elk eenmalig. Heeft de gebruiker er nog, dan logt hij in met eentje en wordt hij gevraagd opnieuw te enrollen.
  2. Support-assisted reset als tweede. De gebruiker dient een formulier in met identiteitsbewijs (laatste vier van de kaart, recent factuurnummer, wat je product ook ondersteunt). Een mens in je team verifieert en zet otp_enabled_at terug op nil. Log elke reset — auditors zullen ernaar vragen.
  3. Nooit een automatische e-mail-only reset. Als het account waardevol genoeg is voor 2FA, is de reset waardevol genoeg voor een mens.

Rails 2FA testen zonder echte authenticator

TOTP testen ziet er intimiderend uit tot je beseft dat het algoritme deterministisch is — je kunt de verwachte code afleiden uit de secret en een vast tijdstip.

# spec/requests/two_factor_challenges_spec.rb
require "rails_helper"

RSpec.describe "Two-factor login", type: :request do
  let(:user) { create(:user, :with_2fa_enabled) }

  around do |example|
    Timecop.freeze(Time.utc(2026, 8, 21, 12, 0, 0)) { example.run }
  end

  it "logt in met een geldige TOTP" do
    post session_path, params: { email: user.email, password: "correct-horse" }
    valid_code = ROTP::TOTP.new(user.otp_secret).now
    post two_factor_challenge_path, params: { code: valid_code }
    expect(response).to redirect_to(dashboard_path)
    expect(session[:user_id]).to eq(user.id)
  end

  it "weigert een afgespeelde code" do
    valid_code = ROTP::TOTP.new(user.otp_secret).now
    user.update!(otp_last_used_at: Time.current)
    post session_path, params: { email: user.email, password: "correct-horse" }
    post two_factor_challenge_path, params: { code: valid_code }
    expect(response).to have_http_status(:unprocessable_entity)
  end
end

Nog twee integration tests die de moeite waard zijn: vijf mislukte pogingen triggeren de lock, en een eenmaal verbruikte back-upcode kan niet nog eens verbruikt worden. Beide zijn regelsass-eenvoudige asserties zodra de setup-helpers bestaan, en beide zijn precies de bugs die je in productie te grazen nemen als je ze overslaat.

Rails 2FA uitrollen naar bestaande gebruikers

Forceer 2FA niet voor iedereen op de dag dat je het launched. Rol enrollment twee weken uit als opt-in, en zet daarna de switch om per gebruikerssegment: admins eerst, betalende klanten tweede, de rest daarna. Meet adoptie in een simpel dashboard — enrollment rate, back-upcode-gebruik, mislukte-poging-locks per dag. Het getal dat je zal verrassen is de lockout-rate; het is bijna altijd een klokafwijking op Android en verdwijnt op het moment dat je drift_ahead verruimt tot 30 seconden.

Stuur de enrollment-nudge vanuit een geauthenticeerde in-app-banner, niet e-mail — het hele punt van Rails 2FA is je afhankelijkheid van e-mail als authenticatiekanaal verminderen. En schrijf de herstelinstructies vóórdat je forceert, niet erna. Elke SaaS die ik door een 2FA-uitrol heb geholpen onderschatte het supportvolume van gebruikers die een nieuwe telefoon kochten en hun authenticator niet migreerden. Tien codes op een enrollment-PDF snijdt dat ticketvolume met 80% weg.

Veelgestelde vragen

Is TOTP in 2026 nog veilig genoeg, of moet ik direct naar passkeys?

Passkeys zijn cryptografisch sterker en phishing-bestendig, wat TOTP niet is. Maar TOTP wordt universeel ondersteund en werkt vandaag voor elke gebruiker met een smartphone. Mijn advies voor de meeste Rails-apps in 2026: ship TOTP eerst omdat je het binnen een week naar 100% van je gebruikers uitrolt, en bied passkeys daarna aan als sterkere opt-in voor gebruikers op moderne apparaten. De twee bestaan naast elkaar — de tweede-factor-beslissing bij login is “TOTP OR passkey OR back-upcode”.

Wat is het verschil tussen de ROTP-gem en devise-two-factor?

rotp is de onderliggende TOTP/HOTP-implementatie zonder frameworkmeningen. devise-two-factor wikkelt rotp in een Devise-strategie met kolomconventies en helpers voor versleutelde attributen. Gebruik je al Devise en wil je het snelste pad, dan bespaart devise-two-factor je een dag. Wil je volledige controle over enrollment, back-upcodes en remembered devices — of gebruik je Rails 8’s ingebouwde authenticate — installeer dan rotp direct. De tachtig regels Ruby in deze post zijn het hele verschil.

Hoe voorkom ik dat een gecompromitteerde database universele 2FA-bypass wordt?

Twee lagen. Ten eerste: versleutel TOTP-secrets at rest met ActiveRecord::Encryption zodat een pg_dump alleen niet genoeg is om geldige codes te genereren. Ten tweede: hash back-upcodes met bcrypt zodat ook die niet uit een databasedump afgespeeld kunnen worden. Een aanvaller die zowel je database als je Rails-masterkey steelt, heeft 2FA gepasseerd — maar diezelfde aanvaller heeft je wachtwoordhashes, sessiecookies en encryption keys, en 2FA ging je daar sowieso niet van redden.

Hoe ga ik om met de gebruiker die zijn telefoon kwijt is en geen back-upcodes heeft?

Bouw geen automatisch herstelpad — dat wordt misbruikt. Bouw een handmatig pad. Een formulier dat identiteitsbewijs verzamelt, een queue die een support-agent afwerkt, een verplichte auditlog-entry, en een nieuwe enrollmentflow na reset. Bewaar automatische e-mail-based bypass voor accounts zonder betekenisvolle data, en wees eerlijk naar gebruikers dat het herstellen van een 2FA-beschermd account werktijden kost. Het alternatief — een stille e-mail-bypass — maakt van je Rails 2FA decoratieve security.

Hulp nodig bij het verharden van authenticatie in een productie-Rails-app? TTB Software is gespecialiseerd in security-kritisch Rails-werk — 2FA, passkeys, SSO, audit logging en de fractional-CTO-supervisie die het zo houdt. We bouwen Rails al negentien jaar in productie.

#rails-2fa #rails-totp #rails-two-factor-authentication #rails-rotp-gem #rails-backup-codes #rails-authenticator-app

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software