CrackedRuby CrackedRuby

Multi-Factor Authentication

Overview

Multi-Factor Authentication (MFA) adds security layers beyond username and password combinations. The system requires users to present two or more verification factors from different categories: something they know (password), something they have (hardware token, mobile device), or something they are (biometric data). Each factor operates independently, creating a defense-in-depth strategy where compromise of a single factor does not grant access.

MFA emerged as a response to password-based authentication vulnerabilities. Passwords suffer from reuse across services, susceptibility to phishing, brute force attacks, and credential stuffing. Organizations implement MFA to protect sensitive data, comply with security regulations, and reduce account compromise risks.

Authentication factors fall into three primary categories. Knowledge factors include passwords, PINs, and security questions. Possession factors encompass physical devices like hardware tokens, smartphones running authenticator apps, or smart cards. Inherence factors rely on biometric characteristics such as fingerprints, facial recognition, or voice patterns.

The authentication flow begins with primary credential verification, typically a password. Upon successful validation, the system prompts for a secondary factor. The user provides the additional verification through a time-based code, hardware token, biometric scan, or push notification confirmation. The system validates all factors before granting access.

# Basic MFA flow structure
class AuthenticationService
  def authenticate(username, password, mfa_code)
    user = User.find_by(username: username)
    return false unless user&.authenticate(password)
    
    verify_mfa_code(user, mfa_code)
  end
  
  def verify_mfa_code(user, code)
    user.mfa_secret && ROTP::TOTP.new(user.mfa_secret).verify(code)
  end
end

MFA adoption has increased across consumer and enterprise applications. Financial institutions require MFA for online banking. Cloud service providers enforce MFA for administrative accounts. Government systems mandate MFA for accessing classified information. Healthcare applications implement MFA to protect patient records and comply with HIPAA requirements.

Key Principles

Multi-Factor Authentication operates on the principle of independent factor verification. Each authentication factor must originate from a different category, ensuring that compromising one factor does not automatically compromise others. A system requiring two passwords does not qualify as MFA because both factors come from the knowledge category.

Factor independence prevents correlated failures. An attacker who steals a password through phishing cannot simultaneously obtain a hardware token without physical access. An attacker who clones a mobile device cannot access biometric data stored securely on the original device. The separation creates multiple attack surfaces that an adversary must breach independently.

Time-Based One-Time Passwords (TOTP) generate temporary codes using a shared secret and the current timestamp. The algorithm combines the secret key with the current time interval, applies HMAC-SHA1 hashing, and extracts a fixed-length numeric code. Both client and server perform identical calculations, comparing the generated codes for validation. TOTP codes typically expire within 30-60 seconds, limiting the window for interception and replay attacks.

# TOTP generation mechanism
require 'rotp'
require 'base32'

secret = Base32.random_base32(32)
totp = ROTP::TOTP.new(secret)

# Current code
current_code = totp.now
# => "123456"

# Verify code with drift tolerance
totp.verify(current_code, drift_behind: 15, drift_ahead: 15)
# => timestamp if valid, nil if invalid

HMAC-Based One-Time Passwords (HOTP) use a counter instead of timestamps. Each code generation increments the counter, ensuring each code is unique and used only once. The server maintains a counter synchronized with the client, validating codes by comparing a range of counter values to account for desynchronization. HOTP suits scenarios where time synchronization is unreliable but sequential code generation is acceptable.

U2F (Universal 2nd Factor) and its successor WebAuthn use public key cryptography for authentication. During registration, the security key generates a public-private key pair specific to the service. The server stores the public key, while the private key never leaves the hardware device. Authentication requires the user to physically interact with the security key, which signs a challenge using the private key. The server verifies the signature with the stored public key. This approach resists phishing because the security key only responds to the legitimate domain.

Recovery mechanisms address scenarios where users lose access to their second factor. Backup codes provide one-time-use codes generated during MFA setup. Users store these codes securely and use them when the primary second factor is unavailable. Recovery codes should be cryptographically random, single-use, and invalidated after use.

# Recovery code generation
require 'securerandom'

def generate_recovery_codes(count = 10)
  count.times.map do
    SecureRandom.hex(4).scan(/.{4}/).join('-').upcase
  end
end

codes = generate_recovery_codes
# => ["A3B4-F8E2", "C9D1-7A5B", ...]

Step-up authentication applies MFA selectively based on risk assessment. Users performing low-risk operations proceed with primary authentication. High-risk operations like changing security settings, transferring funds, or accessing sensitive data trigger MFA prompts. This balances security with user experience, applying additional friction only when necessary.

Security Implications

SMS-based MFA introduces vulnerabilities through SIM swapping attacks. Attackers convince mobile carriers to transfer a victim's phone number to a SIM card under their control. Once the number transfers, SMS codes route to the attacker's device. The attack succeeds through social engineering, exploiting weak carrier verification processes, or bribing carrier employees. Organizations should treat SMS as a backup option rather than primary MFA mechanism.

Time synchronization issues create authentication windows attackers can exploit. TOTP implementations include drift tolerance, accepting codes from several time intervals before or after the current time. This tolerance window mitigates clock skew between client and server but extends the validity period for stolen codes. A 30-second TOTP with 15-second drift allows codes to remain valid for 60 seconds total. Tighter tolerances improve security but increase false negatives from legitimate timing variations.

# Configurable TOTP with security considerations
class SecureTOTP
  def initialize(secret, interval: 30, drift_behind: 1, drift_ahead: 1)
    @totp = ROTP::TOTP.new(secret, interval: interval)
    @drift_behind = drift_behind
    @drift_ahead = drift_ahead
  end
  
  def verify(code, allow_reuse: false)
    timestamp = @totp.verify(
      code,
      drift_behind: @drift_behind,
      drift_ahead: @drift_ahead
    )
    
    return false unless timestamp
    return false if code_recently_used?(timestamp) && !allow_reuse
    
    record_code_usage(timestamp)
    true
  end
  
  private
  
  def code_recently_used?(timestamp)
    # Check if code from this timestamp was used recently
    # Implementation depends on storage mechanism
  end
  
  def record_code_usage(timestamp)
    # Store timestamp to prevent replay attacks
  end
end

Phishing-resistant MFA methods prevent credential theft through fraudulent authentication prompts. TOTP codes remain vulnerable because users manually enter them, potentially into phishing sites. WebAuthn and hardware security keys resist phishing by cryptographically binding authentication to the specific domain. The security key verifies the domain before responding, refusing to authenticate for fraudulent sites even if they perfectly mimic the legitimate interface.

Account recovery processes create security weaknesses when implemented poorly. Email-based recovery allows attackers who compromise email accounts to reset MFA settings. Security question recovery fails when answers become publicly discoverable through social media or data breaches. Identity verification through customer support introduces social engineering risks. Recovery mechanisms should require multiple verification steps and trigger security alerts to the account holder.

Backup codes stored insecurely negate MFA protection. Users who save backup codes in password managers, email drafts, or note-taking applications concentrate risk. Compromise of the storage location exposes all recovery codes simultaneously. Organizations should educate users about secure backup code storage, recommend physical storage in secure locations, and limit backup code quantity.

# Secure backup code implementation
class BackupCode
  def self.generate_set(user_id, count = 10)
    codes = count.times.map { generate_code }
    hashed_codes = codes.map { |code| hash_code(code) }
    
    # Store only hashed versions
    BackupCodeRecord.create!(
      user_id: user_id,
      hashed_codes: hashed_codes,
      created_at: Time.current
    )
    
    codes # Return plaintext once for user to save
  end
  
  def self.verify_and_invalidate(user_id, code)
    hashed = hash_code(code)
    record = BackupCodeRecord.find_by(
      user_id: user_id,
      hashed_codes: hashed
    )
    
    return false unless record
    
    # Remove used code from stored hashes
    record.hashed_codes.delete(hashed)
    record.save!
    true
  end
  
  private
  
  def self.generate_code
    SecureRandom.alphanumeric(16).upcase
  end
  
  def self.hash_code(code)
    Digest::SHA256.hexdigest(code)
  end
end

Session management after MFA authentication determines security scope. MFA validation creates an authenticated session, but the session lifetime affects security posture. Long-lived sessions reduce MFA frequency but extend the window where stolen session tokens grant access. Short sessions increase security but frustrate users with repeated authentication. Adaptive session management adjusts timeouts based on risk signals like unusual location, device, or behavior patterns.

Rate limiting protects against brute force attacks on MFA codes. Six-digit TOTP codes offer one million possible combinations, making them vulnerable to automated guessing within the validity window. Rate limiting restricts authentication attempts per time period, increasing the difficulty of brute force attacks. Account lockout after failed attempts adds protection but enables denial-of-service attacks where adversaries deliberately lock out legitimate users.

Ruby Implementation

The rotp gem provides TOTP and HOTP implementations for Ruby applications. The library handles the cryptographic operations, time-based code generation, and verification with drift tolerance. Applications using rotp should store secret keys securely, never exposing them in logs, error messages, or client-side code.

# Complete TOTP setup flow
require 'rotp'
require 'rqrcode'

class MFASetup
  def initialize(user)
    @user = user
  end
  
  def generate_secret
    @user.mfa_secret = ROTP::Base32.random_base32
    @user.save!
    @user.mfa_secret
  end
  
  def provisioning_uri
    totp = ROTP::TOTP.new(@user.mfa_secret, issuer: 'MyApp')
    totp.provisioning_uri(@user.email)
  end
  
  def qr_code
    uri = provisioning_uri
    qrcode = RQRCode::QRCode.new(uri)
    qrcode.as_png(size: 300)
  end
  
  def verify_setup(code)
    totp = ROTP::TOTP.new(@user.mfa_secret)
    return false unless totp.verify(code, drift_behind: 15, drift_ahead: 15)
    
    @user.update!(mfa_enabled: true)
    true
  end
end

# Usage
setup = MFASetup.new(user)
setup.generate_secret
qr_image = setup.qr_code
# Display QR code to user
verified = setup.verify_setup(user_entered_code)

Devise, a popular authentication solution, supports MFA through the devise-two-factor gem. The gem extends Devise's authentication flow, adding TOTP verification after successful password authentication. Integration requires database schema changes to store encrypted OTP secrets and recovery codes.

# Devise integration
class User < ApplicationRecord
  devise :two_factor_authenticatable,
         :otp_secret_encryption_key => ENV['OTP_SECRET_ENCRYPTION_KEY']
         
  devise :two_factor_backupable, 
         :otp_number_of_backup_codes => 10
end

class SessionsController < Devise::SessionsController
  def create
    super do |resource|
      if resource.otp_required_for_login?
        sign_out resource
        session[:otp_user_id] = resource.id
        redirect_to user_two_factor_authentication_path and return
      end
    end
  end
end

class TwoFactorAuthenticationController < ApplicationController
  def show
    @user = User.find(session[:otp_user_id])
  end
  
  def create
    user = User.find(session[:otp_user_id])
    
    if user.validate_and_consume_otp!(params[:otp_attempt])
      sign_in user
      redirect_to root_path
    else
      flash.now[:error] = "Invalid code"
      render :show
    end
  end
end

WebAuthn support in Ruby requires the webauthn gem for credential creation and authentication ceremonies. The gem handles the complex WebAuthn protocol, including challenge generation, credential storage, and signature verification.

# WebAuthn implementation
require 'webauthn'

class WebauthnController < ApplicationController
  def credential_creation_options
    options = WebAuthn::Credential.options_for_create(
      user: {
        id: current_user.webauthn_id,
        name: current_user.email,
        display_name: current_user.name
      },
      exclude: current_user.webauthn_credentials.map(&:external_id)
    )
    
    session[:creation_challenge] = options.challenge
    render json: options
  end
  
  def register
    webauthn_credential = WebAuthn::Credential.from_create(params)
    
    begin
      webauthn_credential.verify(session[:creation_challenge])
      
      current_user.webauthn_credentials.create!(
        external_id: webauthn_credential.id,
        public_key: webauthn_credential.public_key,
        sign_count: webauthn_credential.sign_count
      )
      
      render json: { success: true }
    rescue WebAuthn::Error => e
      render json: { error: e.message }, status: :unprocessable_entity
    end
  end
  
  def credential_request_options
    options = WebAuthn::Credential.options_for_get(
      allow: current_user.webauthn_credentials.map(&:external_id)
    )
    
    session[:authentication_challenge] = options.challenge
    render json: options
  end
  
  def authenticate
    webauthn_credential = WebAuthn::Credential.from_get(params)
    stored_credential = current_user.webauthn_credentials.find_by(
      external_id: webauthn_credential.id
    )
    
    begin
      webauthn_credential.verify(
        session[:authentication_challenge],
        public_key: stored_credential.public_key,
        sign_count: stored_credential.sign_count
      )
      
      stored_credential.update!(sign_count: webauthn_credential.sign_count)
      session[:webauthn_verified] = true
      
      render json: { success: true }
    rescue WebAuthn::Error => e
      render json: { error: e.message }, status: :unauthorized
    end
  end
end

Encrypted storage of MFA secrets protects against database compromise. Rails provides encrypts attribute encryption using Active Record encryption, introduced in Rails 7. The encryption key should be stored separately from the application code, typically in environment variables or secrets management systems.

# Encrypted MFA secret storage
class User < ApplicationRecord
  encrypts :mfa_secret
  encrypts :backup_codes, deterministic: false
  
  def enable_mfa!
    transaction do
      self.mfa_secret = ROTP::Base32.random_base32
      self.backup_codes = generate_backup_codes
      self.mfa_enabled = true
      save!
    end
  end
  
  def verify_mfa(code)
    return verify_totp(code) if totp_code?(code)
    return verify_backup_code(code) if backup_code?(code)
    false
  end
  
  private
  
  def verify_totp(code)
    ROTP::TOTP.new(mfa_secret).verify(code, drift_behind: 15, drift_ahead: 15)
  end
  
  def verify_backup_code(code)
    return false unless backup_codes.include?(hash_code(code))
    
    backup_codes.delete(hash_code(code))
    save!
    true
  end
  
  def hash_code(code)
    Digest::SHA256.hexdigest(code.upcase.strip)
  end
  
  def generate_backup_codes
    10.times.map { SecureRandom.hex(8).upcase }
  end
  
  def totp_code?(code)
    code.match?(/^\d{6}$/)
  end
  
  def backup_code?(code)
    code.length >= 12
  end
end

Practical Examples

A Rails application implementing progressive MFA enables the feature for users who opt in while keeping it optional. The implementation provides setup, verification, and recovery flows.

# Progressive MFA implementation
class MfaController < ApplicationController
  before_action :authenticate_user!
  
  def new
    @provisioning_uri = generate_provisioning_uri
    @qr_code = RQRCode::QRCode.new(@provisioning_uri)
  end
  
  def create
    if verify_setup_code(params[:verification_code])
      backup_codes = generate_and_store_backup_codes
      flash[:backup_codes] = backup_codes
      redirect_to mfa_backup_codes_path
    else
      flash.now[:error] = "Invalid verification code"
      render :new
    end
  end
  
  def destroy
    if verify_current_password(params[:password])
      current_user.update!(
        mfa_enabled: false,
        mfa_secret: nil,
        backup_codes: []
      )
      redirect_to settings_path, notice: "MFA disabled"
    else
      flash[:error] = "Invalid password"
      redirect_to settings_path
    end
  end
  
  private
  
  def generate_provisioning_uri
    secret = ROTP::Base32.random_base32
    session[:pending_mfa_secret] = secret
    
    totp = ROTP::TOTP.new(secret, issuer: 'MyApp')
    totp.provisioning_uri(current_user.email)
  end
  
  def verify_setup_code(code)
    secret = session[:pending_mfa_secret]
    totp = ROTP::TOTP.new(secret)
    
    if totp.verify(code, drift_behind: 15, drift_ahead: 15)
      current_user.update!(
        mfa_enabled: true,
        mfa_secret: secret
      )
      session.delete(:pending_mfa_secret)
      true
    else
      false
    end
  end
  
  def generate_and_store_backup_codes
    codes = 10.times.map { generate_recovery_code }
    hashed = codes.map { |c| Digest::SHA256.hexdigest(c) }
    
    current_user.update!(backup_codes: hashed)
    codes
  end
  
  def generate_recovery_code
    "#{SecureRandom.hex(2)}-#{SecureRandom.hex(2)}-#{SecureRandom.hex(2)}".upcase
  end
  
  def verify_current_password(password)
    current_user.authenticate(password)
  end
end

An API service implementing MFA for sensitive operations uses step-up authentication. Regular API requests proceed with bearer token authentication. Operations requiring elevated privileges trigger MFA verification.

# Step-up authentication for API
class ApiController < ApplicationController
  before_action :authenticate_with_token
  before_action :verify_mfa, if: :requires_mfa?
  
  def transfer_funds
    # Requires MFA verification
    amount = params[:amount].to_f
    
    if amount > 1000
      return render json: { error: 'MFA required' }, status: :forbidden unless mfa_verified?
    end
    
    # Process transfer
    render json: { success: true }
  end
  
  def view_balance
    # Does not require MFA
    render json: { balance: current_user.balance }
  end
  
  private
  
  def authenticate_with_token
    token = request.headers['Authorization']&.split(' ')&.last
    @current_user = User.find_by(api_token: token)
    
    render json: { error: 'Unauthorized' }, status: :unauthorized unless @current_user
  end
  
  def requires_mfa?
    sensitive_actions.include?(action_name.to_sym)
  end
  
  def sensitive_actions
    [:transfer_funds, :update_settings, :delete_account]
  end
  
  def verify_mfa
    mfa_code = request.headers['X-MFA-Code']
    
    unless mfa_code && current_user.verify_mfa(mfa_code)
      render json: { 
        error: 'MFA verification required',
        mfa_required: true 
      }, status: :forbidden
    end
    
    session[:mfa_verified_at] = Time.current
  end
  
  def mfa_verified?
    return false unless session[:mfa_verified_at]
    
    # MFA verification valid for 5 minutes
    session[:mfa_verified_at] > 5.minutes.ago
  end
end

A financial application implements device-based MFA, remembering trusted devices to reduce authentication friction while maintaining security.

# Trusted device implementation
class TrustedDevice < ApplicationRecord
  belongs_to :user
  
  before_create :generate_device_token
  
  def self.find_by_fingerprint(user_id, fingerprint)
    where(user_id: user_id, device_fingerprint: fingerprint)
      .where('last_verified_at > ?', 30.days.ago)
      .first
  end
  
  def verify!
    update!(
      last_verified_at: Time.current,
      verification_count: verification_count + 1
    )
  end
  
  private
  
  def generate_device_token
    self.device_token = SecureRandom.hex(32)
  end
end

class SessionsController < ApplicationController
  def create
    user = User.find_by(email: params[:email])
    
    if user&.authenticate(params[:password])
      device = find_or_create_device(user)
      
      if device&.trusted? && !high_risk_login?
        sign_in_user(user, device)
      else
        session[:pending_mfa_user_id] = user.id
        redirect_to mfa_verification_path
      end
    else
      render :new, status: :unprocessable_entity
    end
  end
  
  def verify_mfa
    user = User.find(session[:pending_mfa_user_id])
    code = params[:mfa_code]
    trust_device = params[:trust_device] == '1'
    
    if user.verify_mfa(code)
      device = find_or_create_device(user)
      device.verify! if trust_device
      
      sign_in_user(user, device)
      redirect_to root_path
    else
      flash.now[:error] = "Invalid code"
      render :mfa_form
    end
  end
  
  private
  
  def find_or_create_device(user)
    fingerprint = device_fingerprint
    device = TrustedDevice.find_by_fingerprint(user.id, fingerprint)
    
    device || TrustedDevice.create!(
      user: user,
      device_fingerprint: fingerprint,
      user_agent: request.user_agent,
      ip_address: request.remote_ip
    )
  end
  
  def device_fingerprint
    Digest::SHA256.hexdigest([
      request.user_agent,
      request.headers['Accept-Language'],
      request.headers['Accept-Encoding']
    ].join('|'))
  end
  
  def high_risk_login?
    # Check for suspicious indicators
    unknown_location? || unusual_time? || tor_exit_node?
  end
  
  def sign_in_user(user, device)
    session[:user_id] = user.id
    session[:device_token] = device.device_token
  end
end

Common Pitfalls

Secret key exposure through logging occurs when developers debug MFA implementation. Stack traces, error logs, and debug output may contain secret keys, provisioning URIs, or backup codes. Once exposed in logs, secrets become accessible to anyone with log access, including support staff, operations teams, and attackers who compromise logging infrastructure.

# Vulnerable logging
logger.info "Generated MFA secret: #{user.mfa_secret}" # WRONG

# Safe logging
logger.info "MFA enabled for user #{user.id}"

# Safe error handling
rescue ROTP::Error => e
  logger.error "MFA verification failed for user #{user.id}: #{e.class}"
  # Do not log the actual code or secret
end

Clock synchronization failures cause false negatives in TOTP verification. Virtualized servers, mobile devices with automatic time updates disabled, or systems with incorrect timezone configuration generate codes that fail verification. Generous drift tolerance compensates for minor discrepancies but extends code validity windows. Applications should monitor time synchronization on authentication servers and alert operations teams to significant drift.

Insufficient rate limiting allows brute force attacks against six-digit TOTP codes. Without restrictions, attackers can attempt all million combinations within the 30-60 second validity window if response times are fast enough. Rate limiting must apply at multiple levels: per IP address, per user account, and globally. Account-based limiting prevents targeted attacks, while IP and global limits protect against distributed attacks.

# Multi-level rate limiting
class MfaRateLimiter
  def initialize(user_id, ip_address)
    @user_id = user_id
    @ip_address = ip_address
    @redis = Redis.current
  end
  
  def check_limits!
    raise RateLimitError, "Account locked" if account_locked?
    raise RateLimitError, "Too many attempts" if attempts_exceeded?
    
    record_attempt
  end
  
  def record_failure
    increment_failure_count
    lock_account if too_many_failures?
  end
  
  private
  
  def attempts_exceeded?
    user_attempts >= 5 || ip_attempts >= 20
  end
  
  def user_attempts
    @redis.get("mfa_attempts:user:#{@user_id}").to_i
  end
  
  def ip_attempts
    @redis.get("mfa_attempts:ip:#{@ip_address}").to_i
  end
  
  def record_attempt
    @redis.multi do
      @redis.incr("mfa_attempts:user:#{@user_id}")
      @redis.expire("mfa_attempts:user:#{@user_id}", 300)
      @redis.incr("mfa_attempts:ip:#{@ip_address}")
      @redis.expire("mfa_attempts:ip:#{@ip_address}", 300)
    end
  end
  
  def increment_failure_count
    @redis.incr("mfa_failures:user:#{@user_id}")
    @redis.expire("mfa_failures:user:#{@user_id}", 3600)
  end
  
  def too_many_failures?
    @redis.get("mfa_failures:user:#{@user_id}").to_i >= 10
  end
  
  def lock_account
    @redis.setex("mfa_locked:#{@user_id}", 3600, "1")
  end
  
  def account_locked?
    @redis.exists?("mfa_locked:#{@user_id}")
  end
end

Code reuse attacks exploit implementations that accept previously validated codes. TOTP codes remain valid for their entire time window, typically 30 seconds. An attacker who intercepts a code during transmission can reuse it within the validity period. Applications must track recently used codes and reject duplicates, even within the valid time window.

Missing backup code single-use enforcement allows replay attacks. Backup codes should invalidate immediately after successful use. Implementations that check backup codes without removing them from the valid set allow attackers to reuse stolen backup codes repeatedly. The verification and invalidation must occur atomically to prevent race conditions.

Poor QR code generation creates usability and security issues. QR codes must encode the provisioning URI with sufficient error correction for reliable scanning. The URI should include the issuer name, account identifier, and secret in the correct format. Missing or incorrect parameters cause authenticator apps to fail during setup or generate incompatible codes.

# Correct QR code generation
def generate_qr_code_for_display
  totp = ROTP::TOTP.new(
    current_user.mfa_secret,
    issuer: 'MyApp'
  )
  
  uri = totp.provisioning_uri(current_user.email)
  qrcode = RQRCode::QRCode.new(uri, level: :h)
  
  # Generate PNG with appropriate size and border
  qrcode.as_png(
    resize_gte_to: false,
    resize_exactly_to: 300,
    border_modules: 4,
    module_px_size: 6
  )
end

Insecure secret generation uses predictable values or insufficient entropy. Secrets generated from timestamps, user IDs, or sequential numbers allow attackers to predict or enumerate possible values. Base32-encoded secrets should contain at least 160 bits of entropy from cryptographically secure random number generators.

Session fixation after MFA authentication occurs when session identifiers remain unchanged across authentication boundaries. Attackers who obtain a session ID before authentication can hijack the session after the victim completes MFA. Applications must regenerate session identifiers after successful MFA verification to prevent this attack.

Testing Approaches

MFA testing requires validating time-based code generation, verification logic, rate limiting, and recovery flows. Tests must handle time manipulation, simulate various failure scenarios, and verify security properties without exposing secrets in test output.

# RSpec tests for MFA functionality
require 'rails_helper'

RSpec.describe 'MFA Authentication', type: :request do
  let(:user) { create(:user, :with_mfa) }
  let(:totp) { ROTP::TOTP.new(user.mfa_secret) }
  
  describe 'code verification' do
    it 'accepts valid TOTP codes' do
      code = totp.now
      
      post mfa_verify_path, params: { code: code }
      
      expect(response).to redirect_to(root_path)
      expect(session[:user_id]).to eq(user.id)
    end
    
    it 'rejects invalid codes' do
      post mfa_verify_path, params: { code: '000000' }
      
      expect(response).to have_http_status(:unprocessable_entity)
      expect(session[:user_id]).to be_nil
    end
    
    it 'accepts codes within drift tolerance' do
      past_code = totp.at(Time.current - 30)
      
      post mfa_verify_path, params: { code: past_code }
      
      expect(response).to redirect_to(root_path)
    end
    
    it 'rejects codes outside drift tolerance' do
      old_code = totp.at(Time.current - 120)
      
      post mfa_verify_path, params: { code: old_code }
      
      expect(response).to have_http_status(:unprocessable_entity)
    end
  end
  
  describe 'code reuse prevention' do
    it 'rejects previously used codes' do
      code = totp.now
      
      post mfa_verify_path, params: { code: code }
      expect(response).to redirect_to(root_path)
      delete logout_path
      
      post mfa_verify_path, params: { code: code }
      expect(response).to have_http_status(:unprocessable_entity)
    end
  end
  
  describe 'backup codes' do
    let(:backup_codes) { user.generate_backup_codes }
    
    it 'accepts valid backup codes' do
      code = backup_codes.first
      
      post mfa_verify_path, params: { code: code }
      
      expect(response).to redirect_to(root_path)
    end
    
    it 'invalidates backup codes after use' do
      code = backup_codes.first
      
      post mfa_verify_path, params: { code: code }
      delete logout_path
      
      post mfa_verify_path, params: { code: code }
      expect(response).to have_http_status(:unprocessable_entity)
    end
  end
  
  describe 'rate limiting' do
    it 'blocks after excessive failed attempts' do
      11.times do
        post mfa_verify_path, params: { code: '000000' }
      end
      
      code = totp.now
      post mfa_verify_path, params: { code: code }
      
      expect(response).to have_http_status(:too_many_requests)
    end
  end
end

Integration tests verify the complete MFA flow from setup through verification, including QR code generation and backup code handling.

RSpec.describe 'MFA Setup Flow', type: :system do
  let(:user) { create(:user) }
  
  before { sign_in user }
  
  it 'completes full setup process' do
    visit settings_path
    click_link 'Enable Two-Factor Authentication'
    
    expect(page).to have_css('img[alt="QR Code"]')
    expect(page).to have_content('Scan this code')
    
    # Extract secret from provisioning URI
    uri = page.find('input[name="provisioning_uri"]', visible: false).value
    secret = URI.decode_www_form(URI.parse(uri).query).to_h['secret']
    
    # Generate valid code
    totp = ROTP::TOTP.new(secret)
    fill_in 'Verification Code', with: totp.now
    click_button 'Verify and Enable'
    
    expect(page).to have_content('Two-factor authentication enabled')
    expect(page).to have_content('Backup Codes')
    expect(page).to have_css('.backup-code', count: 10)
  end
  
  it 'requires MFA on subsequent logins' do
    user.update!(mfa_enabled: true, mfa_secret: ROTP::Base32.random_base32)
    sign_out
    
    visit login_path
    fill_in 'Email', with: user.email
    fill_in 'Password', with: user.password
    click_button 'Sign In'
    
    expect(page).to have_content('Enter verification code')
    expect(current_path).to eq(mfa_verify_path)
  end
end

Time-dependent code testing requires controlling the current time to generate predictable codes and test time-based edge cases.

RSpec.describe MfaVerification do
  describe 'time-based code generation' do
    it 'generates different codes over time' do
      freeze_time do
        code1 = totp.now
        travel 30.seconds
        code2 = totp.now
        
        expect(code1).not_to eq(code2)
      end
    end
    
    it 'verifies codes at exact time boundaries' do
      freeze_time do
        code = totp.now
        
        # Just before expiration
        travel 29.seconds
        expect(totp.verify(code)).to be_truthy
        
        # Just after expiration
        travel 1.second
        expect(totp.verify(code, drift_behind: 0)).to be_falsey
      end
    end
  end
end

Reference

Authentication Factor Categories

Category Type Examples Security Level
Knowledge Something user knows Password, PIN, security questions Low - vulnerable to phishing
Possession Something user has Hardware token, mobile device, smart card Medium - requires physical theft
Inherence Something user is Fingerprint, facial recognition, iris scan High - difficult to replicate

TOTP Implementation Parameters

Parameter Typical Value Purpose Security Impact
Secret length 160-256 bits Shared secret size Longer provides more entropy
Time interval 30 seconds Code validity period Shorter reduces interception window
Drift behind 1-2 intervals Past code acceptance Balances usability and security
Drift ahead 1 interval Future code acceptance Compensates for clock skew
Code digits 6-8 Code length More digits increase brute force difficulty
Hash algorithm SHA-1, SHA-256 HMAC function SHA-256 preferred for new implementations

Backup Code Best Practices

Aspect Recommendation Rationale
Quantity 10-12 codes Sufficient for emergencies without excess
Format Alphanumeric uppercase Easy to read and transcribe
Length 12-16 characters Balances security and usability
Grouping 4-character segments Improves readability
Storage Hashed with salt Protects against database compromise
Usage Single-use only Prevents replay attacks
Generation Cryptographically random Ensures unpredictability

MFA Security Comparison

Method Phishing Resistant Setup Complexity User Friction Cost
SMS codes No Low Low Low
TOTP apps No Medium Low None
Push notifications Partial Medium Low Low
Hardware tokens (U2F/WebAuthn) Yes High Medium Medium-High
Biometrics Yes High Low High

Rate Limiting Recommendations

Scope Limit Window Purpose
Per user account 5 attempts 5 minutes Prevent targeted brute force
Per IP address 20 attempts 5 minutes Prevent distributed attacks
Per account lockout 10 failures 1 hour Temporary account protection
Global threshold 1000 attempts 1 minute Detect attack campaigns

Recovery Flow Security Checklist

Check Purpose
Multi-factor identity verification Confirm user identity through multiple channels
Security event notification Alert user of recovery attempt via email and SMS
Delayed recovery activation Wait period before recovery completes
Audit log recording Track all recovery attempts for security review
Administrator approval option Manual review for high-value accounts
Recovery code regeneration Invalidate old codes after successful recovery

Session Security After MFA

Configuration Value Use Case
Standard session 24 hours Normal user activity
Elevated privilege session 15 minutes Administrative actions
API session with MFA 1 hour Programmatic access
High-risk transaction Per-transaction Fund transfers, deletions
Remember device 30 days Trusted device recognition

WebAuthn Credential Types

Type Description Use Case
Platform authenticator Built into device (TouchID, Windows Hello) Personal device authentication
Roaming authenticator External hardware key (YubiKey, Titan) Multi-device authentication
Single-device credential Bound to specific device Device-specific security
Cross-platform credential Usable across devices Portable authentication