Multi-Factor Authentication
Build MFA as a pluggable factor framework, not a feature. Ship passkeys and TOTP first, recovery codes always, SMS only as a restricted fallback, and step-up as a policy the rest of your product can call.
The factor framework
Every factor implements the same four operations, so adding one later does not touch the login flow:
| Operation | What it does |
|---|---|
enroll_begin / enroll_finish |
Create the factor, prove possession once, store it |
challenge |
Start a verification (send a code, create a WebAuthn challenge) |
verify |
Check the response; return the methods used for the session's amr |
remove |
Delete it, only after step-up, and notify the user |
Store enrolled factors per user. After verification, update the session's authentication level and methods (Chapter 3). Downstream code checks the level, never the factor type.
Factor choices
| Factor | Phishing-resistant | Build notes |
|---|---|---|
| Passkey or security key (WebAuthn) | Yes | Chapter 8. Your strongest default. |
| TOTP authenticator app | No | Easy to build, works offline, widely understood |
| Push with number matching | No | Requires your own mobile app; never ship push without number matching (CISA) |
| Email one-time code | No | Fine for low-risk step-up and recovery, weak as a true second factor if the inbox is the same identity |
| SMS or voice code | No | NIST treats phone-network delivery as a restricted authenticator: allowed only with a risk assessment and a non-restricted alternative offered (NIST) |
| Recovery codes | No | Required as a backup for every user with MFA |
TOTP, step by step (RFC 6238)
- Generate a 160-bit random secret. Encrypt it with a KMS key before storing it.
- Show it as a QR code using the
otpauth://totp/{issuer}:{account}?secret={base32}&issuer={issuer}URI, plus the text secret for manual entry. - Require the user to enter one valid code before you activate the factor.
- Verify codes with a 30-second step and 6 digits. Accept the current step plus one step either side for clock drift.
- Store the last accepted time step per factor and reject any code from that step or earlier. This blocks replay.
- Rate-limit verification per factor (for example, 5 attempts then a growing delay). Six digits is only one million combinations.
Recovery codes
- Generate 10 codes with at least 50 bits of randomness each, formatted for humans (for example
7KQ4-MX2P-9HTA). - Store them hashed like passwords. Show them once. Mark each used on success.
- Let users regenerate the set after step-up, which invalidates the old set.
Step-up authentication
Step-up asks for fresh proof before sensitive actions, without ending the session. Implement it once as a policy service:
def require(session, level=2, max_age_seconds=300):
fresh = now() - session.auth_time <= max_age_seconds
if session.aal >= level and fresh:
return OK
return STEP_UP_REQUIRED # client starts a passkey or TOTP challenge
For OAuth clients, expose this through OpenID Connect acr_values and max_age on the authorization request, and return acr, amr, and auth_time in the ID token (Chapter 9).
Remembered devices
After MFA on a new device, offer "trust this device." Store a separate long-lived, signed device token bound to the user. Skip MFA on that device for normal sign-ins, but never for step-up actions. Revoke all device tokens on password change or recovery.
Common mistakes
- No replay protection on TOTP codes.
- MFA enforced at sign-in but not on the recovery path, which becomes the bypass.
- Removing a factor without step-up or notification.
- Prompting for a code after a passkey sign-in. The passkey already proved two factors.
Key takeaways
- Build MFA as a factor framework with four operations, so new factors never touch the login flow.
- Ship passkeys and TOTP first, recovery codes always, SMS only as a restricted fallback.
- Block TOTP replay by storing the last accepted time step.
- Implement step-up once as a policy service; downstream code checks the level, never the factor.