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.
Setup Flow
Section titled “Setup Flow”-
Initialize setup
Call
/auth/mfa/initto generate a secret and QR code URI for the user to scan:EndpointPOST /auth/mfa/init Authorization: Bearer <access-token>POST /auth/mfa/init Authorization: Bearer <access-token>Programmaticsetup = await auth.enable_mfa_init(user_id) # setup.secret — base32 TOTP secret # setup.qr_uri — otpauth:// URI for QR code generationsetup = await auth.enable_mfa_init(user_id) # setup.secret — base32 TOTP secret # setup.qr_uri — otpauth:// URI for QR code generationThe
qr_uriis anotpauth://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 clientMFA is not enabled yet at this point — the user hasn’t verified they scanned it correctly.
-
Confirm setup
After the user scans the QR code, ask them to enter the first TOTP code from their app:
EndpointPOST /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"}Programmaticbackup_codes = await auth.enable_mfa_confirm(user_id, totp_code) # Returns a list of 10 plaintext backup codes — show once, never againbackup_codes = await auth.enable_mfa_confirm(user_id, totp_code) # Returns a list of 10 plaintext backup codes — show once, never againOn 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.
Login Flow
Section titled “Login Flow”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 userThe 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.
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 = resultfrom 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 = resultBackup Codes
Section titled “Backup Codes”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.
Disable MFA
Section titled “Disable MFA”Users can disable MFA by providing a 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 codePOST /auth/mfa/disable
Authorization: Bearer <access-token>
Content-Type: application/json
{"code": "847291"} # valid TOTP code or backup codeawait auth.disable_mfa(user_id, totp_or_backup_code)await auth.disable_mfa(user_id, totp_or_backup_code)Admin Override
Section titled “Admin Override”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.
Backup Code Management
Section titled “Backup Code Management”Regenerate Backup Codes
Section titled “Regenerate Backup Codes”Invalidates all existing backup codes and generates a new set. Requires 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 codePOST /auth/mfa/backup-codes/regenerate
Authorization: Bearer <access-token>
Content-Type: application/json
{"code": "847291"} # must be a valid TOTP codenew_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.Check Status
Section titled “Check Status”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
}status = await auth.get_mfa_status(user_id)
# status.enabled — bool
# status.backup_codes_remaining — intstatus = await auth.get_mfa_status(user_id)
# status.enabled — bool
# status.backup_codes_remaining — intWorkspace / Posture Check
Section titled “Workspace / Posture Check”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.
Configuration
Section titled “Configuration”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)
mfa_max_failed_attempts=5, # lock MFA login after N failed codes (0 disables)
mfa_lockout_seconds=900, # how long the lock lasts (default: 15 min)
)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)
mfa_max_failed_attempts=5, # lock MFA login after N failed codes (0 disables)
mfa_lockout_seconds=900, # how long the lock lasts (default: 15 min)
)| Option | Type | Default | Description |
|---|---|---|---|
mfa_issuer |
str | None |
None (uses jwt_issuer) |
App name shown in the authenticator app |
mfa_backup_code_count |
int |
10 |
Number of backup codes generated per user |
mfa_max_failed_attempts |
int |
5 |
Consecutive failed verifications before MFA login locks. 0 disables lockout. |
mfa_lockout_seconds |
int |
900 |
How long MFA login stays locked after too many failures |
Security
Section titled “Security”| Property | Detail |
|---|---|
| Algorithm | TOTP (RFC 6238), 30s windows, ±1 window drift tolerance |
| Replay protection | A used code is rejected for its full 90s validity horizon (strict one-time use) |
| Brute-force lockout | After mfa_max_failed_attempts failures, /auth/mfa/verify returns 429 mfa_locked for mfa_lockout_seconds. DB-backed (holds across workers/replicas); a success resets the counter |
| Code input | Whitespace-tolerant — the 123 456 form authenticator apps display is accepted |
| Backup codes | SHA-256 hashed, single-use, xxxxx-xxxxx format |
| Challenge token | Short-lived JWT (5 min), purpose-scoped — cannot be used as an access token; survives key rotation via kid lookup |
| OAuth redirect | mfa_token is delivered in the URL fragment (#mfa_token=...), keeping it out of logs and Referer |
| Posture claim | Enabling/disabling MFA bumps token_version, so the mfa_enabled JWT claim can’t go stale |
| Rate limiting | /auth/mfa/verify is additionally rate-limited via RateLimitConfig(mfa_verify="5/min") |
Events
Section titled “Events”| Event | Fired when |
|---|---|
mfa_enabled |
User enables MFA |
mfa_disabled |
User or admin disables MFA |
mfa_login |
MFA step completed successfully |
mfa_failed |
TOTP or backup code rejected |
mfa_locked |
MFA login locked after too many failed attempts (fires once, at the threshold) |
backup_code_used |
A backup code was consumed |
backup_codes_regenerated |
Backup codes were regenerated |
See Events & Hooks for full payload details.
Client SDK
Section titled “Client SDK”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.