MFA
Multi-factor authentication with TOTP, backup codes, and SMS
MFA Plugin
The MFA (Multi-Factor Authentication) plugin adds three independent verification methods: TOTP (Time-based One-Time Passwords), backup codes, and SMS codes. Each can be used standalone or in combination.
Setup
class User < ApplicationRecord
include Custos::Authenticatable
custos do
plugin :mfa
on(:sms_code_created) do |record, code|
SmsService.send(record.phone, "Your verification code: #{code}")
end
end
endRun the model generator:
rails generate custos:model User mfa
rails db:migrateThis creates the custos_mfa_credentials table.
TOTP (Time-based One-Time Passwords)
TOTP works with authenticator apps like Google Authenticator, Authy, and 1Password.
setup_totp(issuer:)
Generates a TOTP secret and returns a provisioning URI. The user scans this as a QR code in their authenticator app.
provisioning_uri = user.setup_totp(issuer: "MyApp")
# => "otpauth://totp/MyApp:alice%40example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp"At this point TOTP is not yet enabled. The user must confirm by entering a valid code.
confirm_totp!(code)
Confirms TOTP setup by verifying a code from the authenticator app. Returns true on success, false if the code is invalid. After confirmation, TOTP is marked as enabled.
user.confirm_totp!("123456")
# => trueverify_totp(code)
Verifies a TOTP code during sign-in. Allows a 30-second drift in both directions.
user.verify_totp("123456")
# => truetotp_enabled?
Returns whether TOTP has been set up and confirmed for this user.
user.totp_enabled?
# => trueBackup Codes
Backup codes are one-time recovery codes in case the user loses access to their authenticator app.
generate_backup_codes(count:)
Generates a set of one-time backup codes. Returns the plain-text codes -- display these to the user once. Only the digests are stored.
codes = user.generate_backup_codes(count: 10)
# => ["a1b2c3d4", "e5f6g7h8", "i9j0k1l2", ...]Default count is 10.
verify_backup_code(code)
Verifies a backup code. If valid, the code is consumed (removed from the stored digests) and cannot be reused. Returns true or false.
user.verify_backup_code("a1b2c3d4")
# => true
# Same code again:
user.verify_backup_code("a1b2c3d4")
# => false (already consumed)SMS Codes
SMS codes are 6-digit codes sent to the user's phone number.
send_sms_code
Generates a 6-digit code, stores its digest with an expiry, and fires the :sms_code_created callback. Returns true.
user.send_sms_code
# Fires :sms_code_created callback with the user and the codeThe callback handles delivery:
on(:sms_code_created) do |record, code|
SmsService.send(record.phone, "Your code: #{code}")
endverify_sms_code(code)
Verifies the SMS code. Checks both the digest match and the expiry (5 minutes by default).
user.verify_sms_code("482917")
# => trueGeneral Methods
mfa_enabled?
Returns true if any MFA method (TOTP or SMS) is currently enabled.
user.mfa_enabled?
# => trueController Example
Setting Up TOTP
class Mfa::TotpController < ApplicationController
before_action :custos_authenticate!
def new
@provisioning_uri = custos_current.setup_totp(issuer: "MyApp")
# Render a page with a QR code for this URI
end
def create
if custos_current.confirm_totp!(params[:code])
@backup_codes = custos_current.generate_backup_codes
# Show backup codes to the user
else
flash[:alert] = "Invalid code. Please try again."
redirect_to new_mfa_totp_path
end
end
endVerifying During Sign-In
class Mfa::VerificationsController < ApplicationController
def create
user = User.find(session[:pending_mfa_user_id])
verified = if params[:backup_code].present?
user.verify_backup_code(params[:backup_code])
elsif params[:sms_code].present?
user.verify_sms_code(params[:sms_code])
else
user.verify_totp(params[:totp_code])
end
if verified
_session, token = Custos::SessionManager.create(user, request: request)
cookies.signed[:custos_session_token] = {
value: token,
httponly: true,
secure: Rails.env.production?
}
session.delete(:pending_mfa_user_id)
redirect_to dashboard_path
else
flash[:alert] = "Invalid verification code."
render :new, status: :unprocessable_entity
end
end
endDatabase Schema
custos_mfa_credentials
| Column | Type | Notes |
|---|---|---|
id | bigint | Primary key |
authenticatable_type | string | Polymorphic type |
authenticatable_id | bigint | Polymorphic ID |
method | string | totp, backup_codes, or sms |
secret_data | text | Encrypted with AES-256-GCM when mfa_encryption_key is configured |
enabled_at | datetime | nil until method is confirmed |
created_at | datetime | Creation timestamp |
updated_at | datetime | Update timestamp |
Unique index on [authenticatable_type, authenticatable_id, method].
MFA Rate Limiting
When the Lockout plugin is enabled alongside MFA, failed MFA attempts are tracked independently via the :after_mfa_verification hook.
| Option | Type | Default | Description |
|---|---|---|---|
max_mfa_attempts | Integer | 5 | Failed MFA attempts before lockout |
mfa_lockout_duration | Integer | Same as lockout_duration | MFA lockout duration in seconds |
This requires two additional columns on your model's table:
| Column | Type | Default | Notes |
|---|---|---|---|
failed_mfa_count | integer | 0 | Tracks consecutive MFA failures |
mfa_locked_at | datetime | nil | When MFA was locked |
These columns are included in the lockout migration generated by rails generate custos:model.
Instance Methods
mfa_locked?-- returnstrueif MFA is currently lockedrecord_failed_mfa_attempt!-- increments the MFA failure counter atomicallyreset_failed_mfa_attempts!-- resets the counter (called on successful MFA verification)
Security Notes
- TOTP secrets are encrypted with AES-256-GCM when
mfa_encryption_keyis configured; stored in plaintext otherwise - Backup codes have 48-bit entropy (
SecureRandom.hex(6)), stored as HMAC-SHA256 digests; plain-text codes are shown once - SMS codes are stored as HMAC-SHA256 digests with a 5-minute expiry
- TOTP verification allows a 30-second drift to accommodate clock differences
- Backup codes are consumed on use (one-time only) with pessimistic locking to prevent race conditions
- All comparisons use timing-safe operations