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 |