Skip to content

TOTP MFA

AuthFort supports TOTP MFA (RFC 6238) — the same protocol used by Google Authenticator, Authy, 1Password, and any standard authenticator app. MFA is an account-level property: once enabled, every login requires a 6-digit code in addition to the password.

TOTP support is built into AuthFort — no extra dependencies needed.

  1. Initialize setup

    Call /auth/mfa/init to generate a secret and QR code URI for the user to scan:

    Endpoint
    POST /auth/mfa/init
    Authorization: Bearer <access-token>
    POST /auth/mfa/init
    Authorization: Bearer <access-token>
    Programmatic
    setup = await auth.enable_mfa_init(user_id)
    # setup.secret  — base32 TOTP secret
    # setup.qr_uri  — otpauth:// URI for QR code generation
    setup = await auth.enable_mfa_init(user_id)
    # setup.secret  — base32 TOTP secret
    # setup.qr_uri  — otpauth:// URI for QR code generation

    The qr_uri is an otpauth:// URI. Encode it into a QR image and show it to the user:

    # Generate a QR image (e.g. with qrcode library)
    import qrcode, io
    img = qrcode.make(setup.qr_uri)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    qr_png = buf.getvalue()  # send to client
    # Generate a QR image (e.g. with qrcode library)
    import qrcode, io
    img = qrcode.make(setup.qr_uri)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    qr_png = buf.getvalue()  # send to client

    MFA is not enabled yet at this point — the user hasn’t verified they scanned it correctly.

  2. Confirm setup

    After the user scans the QR code, ask them to enter the first TOTP code from their app:

    Endpoint
    POST /auth/mfa/confirm
    Authorization: Bearer <access-token>
    Content-Type: application/json
    
    {"code": "847291"}
    POST /auth/mfa/confirm
    Authorization: Bearer <access-token>
    Content-Type: application/json
    
    {"code": "847291"}
    Programmatic
    backup_codes = await auth.enable_mfa_confirm(user_id, totp_code)
    # Returns a list of 10 plaintext backup codes — show once, never again
    backup_codes = await auth.enable_mfa_confirm(user_id, totp_code)
    # Returns a list of 10 plaintext backup codes — show once, never again

    On success, MFA is enabled and the response contains 10 backup codes. Show these to the user exactly once — they cannot be retrieved again. Instruct them to save the codes in a safe place.

Once MFA is enabled, POST /auth/login returns an MFAChallenge instead of tokens:

# Step 1: Login (same as always)
POST /auth/login
Content-Type: application/json

{"email": "user@example.com", "password": "strongpassword"}

# Response when MFA is enabled:
{
  "mfa_required": true,
  "mfa_token": "eyJ...",   # short-lived JWT (5 min)
  "expires_in": 300
}

# Step 2: Submit TOTP code
POST /auth/mfa/verify
Content-Type: application/json

{"mfa_token": "eyJ...", "code": "847291"}

# Response: normal AuthResponse with tokens and user
# Step 1: Login (same as always)
POST /auth/login
Content-Type: application/json

{"email": "user@example.com", "password": "strongpassword"}

# Response when MFA is enabled:
{
  "mfa_required": true,
  "mfa_token": "eyJ...",   # short-lived JWT (5 min)
  "expires_in": 300
}

# Step 2: Submit TOTP code
POST /auth/mfa/verify
Content-Type: application/json

{"mfa_token": "eyJ...", "code": "847291"}

# Response: normal AuthResponse with tokens and user

The mfa_token is a short-lived signed JWT (5 minutes). Submit it along with the TOTP code to /auth/mfa/verify to receive full auth tokens.

Programmatic
from authfort.core.schemas import MFAChallenge

result = await auth.login(email, password)

if isinstance(result, MFAChallenge):
    # Prompt user for TOTP code, then:
    auth_response = await auth.complete_mfa_login(result.mfa_token, totp_code)
else:
    # Normal login — tokens in result.tokens
    auth_response = result
from authfort.core.schemas import MFAChallenge

result = await auth.login(email, password)

if isinstance(result, MFAChallenge):
    # Prompt user for TOTP code, then:
    auth_response = await auth.complete_mfa_login(result.mfa_token, totp_code)
else:
    # Normal login — tokens in result.tokens
    auth_response = result

If the user doesn’t have their authenticator app, they can submit a backup code instead:

# A backup code works in place of a TOTP code at /auth/mfa/verify
POST /auth/mfa/verify
Content-Type: application/json

{"mfa_token": "eyJ...", "code": "x7k2m-9pqnr"}
# A backup code works in place of a TOTP code at /auth/mfa/verify
POST /auth/mfa/verify
Content-Type: application/json

{"mfa_token": "eyJ...", "code": "x7k2m-9pqnr"}

Backup codes are single-use — the used code is immediately invalidated.

Users can disable MFA by providing a valid TOTP code or backup code:

Endpoint
POST /auth/mfa/disable
Authorization: Bearer <access-token>
Content-Type: application/json

{"code": "847291"}  # valid TOTP code or backup code
POST /auth/mfa/disable
Authorization: Bearer <access-token>
Content-Type: application/json

{"code": "847291"}  # valid TOTP code or backup code
Programmatic
await auth.disable_mfa(user_id, totp_or_backup_code)
await auth.disable_mfa(user_id, totp_or_backup_code)

If a user loses their authenticator and all backup codes, an admin can disable MFA without a code:

# Admin override — no TOTP code needed
# Use for account recovery when the user has lost their authenticator
await auth.admin_disable_mfa(user_id)
# Admin override — no TOTP code needed
# Use for account recovery when the user has lost their authenticator
await auth.admin_disable_mfa(user_id)

Verify the user’s identity out-of-band before calling this.

Invalidates all existing backup codes and generates a new set. Requires a valid TOTP code:

Endpoint
POST /auth/mfa/backup-codes/regenerate
Authorization: Bearer <access-token>
Content-Type: application/json

{"code": "847291"}  # must be a valid TOTP code
POST /auth/mfa/backup-codes/regenerate
Authorization: Bearer <access-token>
Content-Type: application/json

{"code": "847291"}  # must be a valid TOTP code
Programmatic
new_codes = await auth.regenerate_backup_codes(user_id, totp_code)
# Returns 10 new plaintext codes. Old codes are immediately invalidated.
new_codes = await auth.regenerate_backup_codes(user_id, totp_code)
# Returns 10 new plaintext codes. Old codes are immediately invalidated.
Endpoint
GET /auth/mfa/status
Authorization: Bearer <access-token>

# Response:
{
  "enabled": true,
  "backup_codes_remaining": 7
}
GET /auth/mfa/status
Authorization: Bearer <access-token>

# Response:
{
  "enabled": true,
  "backup_codes_remaining": 7
}
Programmatic
status = await auth.get_mfa_status(user_id)
# status.enabled               — bool
# status.backup_codes_remaining — int
status = await auth.get_mfa_status(user_id)
# status.enabled               — bool
# status.backup_codes_remaining — int

AuthFort includes an mfa_enabled claim in every access token. Downstream services can check this claim to enforce MFA requirements — with zero latency and no database query:

# In your app's route guard or middleware — no DB query needed
from authfort_service import JWTVerifier

payload = await verifier.verify(access_token)

if workspace.requires_mfa and not payload.mfa_enabled:
    raise HTTPException(403, "MFA required for this workspace")
# In your app's route guard or middleware — no DB query needed
from authfort_service import JWTVerifier

payload = await verifier.verify(access_token)

if workspace.requires_mfa and not payload.mfa_enabled:
    raise HTTPException(403, "MFA required for this workspace")

This pattern works well for multi-tenant apps where some workspaces require MFA. AuthFort sets the claim; your app enforces the policy.

auth = AuthFort(
    database_url="...",
    mfa_issuer="My App",           # shown in authenticator app (default: jwt_issuer)
    mfa_backup_code_count=10,      # backup codes per user (default: 10)
)
auth = AuthFort(
    database_url="...",
    mfa_issuer="My App",           # shown in authenticator app (default: jwt_issuer)
    mfa_backup_code_count=10,      # backup codes per user (default: 10)
)
OptionTypeDefaultDescription
mfa_issuerstr | NoneNone (uses jwt_issuer)App name shown in the authenticator app
mfa_backup_code_countint10Number of backup codes generated per user
PropertyDetail
AlgorithmTOTP (RFC 6238), 30s windows, ±1 window drift tolerance
Replay protectionSame code blocked within the same 30s window
Backup codesSHA-256 hashed, single-use, xxxxx-xxxxx format
Challenge tokenShort-lived JWT (5 min), purpose-scoped — cannot be used as an access token
Rate limiting/auth/mfa/verify is rate-limited via RateLimitConfig(mfa_verify="5/min")
EventFired when
mfa_enabledUser enables MFA
mfa_disabledUser or admin disables MFA
mfa_loginMFA step completed successfully
mfa_failedTOTP or backup code rejected (good target for account lockout)
backup_code_usedA backup code was consumed
backup_codes_regeneratedBackup codes were regenerated

See Events & Hooks for full payload details.

The TypeScript client handles the two-step flow automatically:

const result = await auth.signIn({
  email: 'user@example.com',
  password: 'strongpassword',
});

if (result.status === 'mfa_required') {
  // Prompt the user for their TOTP code
  const code = await promptUser('Enter your authenticator code:');
  const user = await auth.verifyMFA(code);
} else {
  // result.status === 'authenticated'
  const user = result.user;
}
const result = await auth.signIn({
  email: 'user@example.com',
  password: 'strongpassword',
});

if (result.status === 'mfa_required') {
  // Prompt the user for their TOTP code
  const code = await promptUser('Enter your authenticator code:');
  const user = await auth.verifyMFA(code);
} else {
  // result.status === 'authenticated'
  const user = result.user;
}

Framework integrations expose an isMFAPending state for conditional rendering:

// React
const { isMFAPending, client } = useAuth();

if (isMFAPending) {
  return <MFACodeForm onSubmit={(code) => client.verifyMFA(code)} />;
}
// React
const { isMFAPending, client } = useAuth();

if (isMFAPending) {
  return <MFACodeForm onSubmit={(code) => client.verifyMFA(code)} />;
}

See Client MFA for the full client-side guide.