Skip to content

Chapter 7 of 204 min readBy Deepak GuptaPublished

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)

  1. Generate a 160-bit random secret. Encrypt it with a KMS key before storing it.
  2. Show it as a QR code using the otpauth://totp/{issuer}:{account}?secret={base32}&issuer={issuer} URI, plus the text secret for manual entry.
  3. Require the user to enter one valid code before you activate the factor.
  4. Verify codes with a 30-second step and 6 digits. Accept the current step plus one step either side for clock drift.
  5. Store the last accepted time step per factor and reject any code from that step or earlier. This blocks replay.
  6. 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.

Warning

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.