Multi-Factor Authentication (MFA)
This feature is in beta. Core behavior is stable and ready to try, but some APIs or configuration may still evolve before general availability.
Add an extra layer of security with TOTP-based multi-factor authentication. Users enroll using any authenticator app (Google Authenticator, Authy, 1Password, etc.).
Enroll TOTP, Verify Enrollment, Login with MFA, Recovery Codes, Disable MFA, and List MFA Factors are end-user client flows. The admin-only capability on this page is Admin: Force-Disable MFA.
How It Works
- Enrollment: User enrolls TOTP → gets a secret (QR code) + 8 recovery codes
- Verification: User enters a 6-digit code from their authenticator app to confirm enrollment
- Login: After password verification, user must provide a TOTP code to complete sign-in
- Recovery: If the authenticator is lost, a one-time recovery code can be used instead
Configuration
Enable TOTP MFA in your edgebase.config.ts:
export default {
auth: {
mfa: {
totp: true, // Enable TOTP-based MFA
},
},
} satisfies EdgeBaseConfig;
Enrollment Flow
Step 1: Enroll TOTP
The authenticated user starts enrollment. The server returns a secret, QR code URI, and recovery codes.
- JavaScript
- Dart/Flutter
- Swift
- Kotlin
- Java
- C#
- C++
const { factorId, secret, qrCodeUri, recoveryCodes } = await client.auth.mfa.enrollTotp();
// Display QR code (use a QR library like 'qrcode')
// Save recoveryCodes — shown to user ONLY ONCE
final result = await client.auth.mfa.enrollTotp();
print(result.qrCodeUri); // Display as QR code
print(result.recoveryCodes); // Show once to user
let result = try await client.auth.mfa.enrollTotp()
// result.qrCodeUri — display as QR code
// result.recoveryCodes — show once to user
val result = client.auth.mfa.enrollTotp()
// result.qrCodeUri — display as QR code
// result.recoveryCodes — show once to user
Map<String, Object> result = client.auth().enrollTotp();
System.out.println(result.get("qrCodeUri")); // Display as QR code
System.out.println(result.get("recoveryCodes")); // Show once to user
var result = await client.Auth.EnrollTotpAsync();
Console.WriteLine(result["qrCodeUri"]); // Display as QR code
Console.WriteLine(result["recoveryCodes"]); // Show once to user
auto result = client.auth().enrollTotp();
auto data = nlohmann::json::parse(result.body);
// data["qrCodeUri"] — display as QR code
// data["recoveryCodes"] — show once to user
Step 2: Verify Enrollment
After the user scans the QR code and enters a 6-digit code from their authenticator app:
- JavaScript
- Dart/Flutter
- Swift
- Kotlin
- Java
- C#
- C++
await client.auth.mfa.verifyTotpEnrollment(factorId, code);
// MFA is now active for this user
await client.auth.mfa.verifyTotpEnrollment(factorId, code);
try await client.auth.mfa.verifyTotpEnrollment(factorId: factorId, code: code)
client.auth.mfa.verifyTotpEnrollment(factorId, code)
client.auth().verifyTotpEnrollment(factorId, code);
await client.Auth.VerifyTotpEnrollmentAsync(factorId, code);
client.auth().verifyTotpEnrollment(factorId, code);
Login with MFA
When MFA is enabled, signIn returns a challenge instead of session tokens:
- JavaScript
- Dart/Flutter
- Swift
- Kotlin
- Java
- C#
- C++
const result = await client.auth.signIn({ email, password });
if (result.mfaRequired) {
// MFA challenge — prompt user for TOTP code
const session = await client.auth.mfa.verifyTotp(result.mfaTicket, totpCode);
// session.user, session.accessToken, session.refreshToken
} else {
// Normal login — no MFA
// result.user, result.accessToken, result.refreshToken
}
final result = await client.auth.signIn(email: email, password: password);
if (result.mfaRequired) {
final session = await client.auth.mfa.verifyTotp(result.mfaTicket!, code);
// session.user, session.accessToken
} else {
// Normal login
}
let result = try await client.auth.signIn(email: email, password: password)
if result.mfaRequired {
let session = try await client.auth.mfa.verifyTotp(
mfaTicket: result.mfaTicket!,
code: totpCode
)
} else {
// Normal login
}
val result = client.auth.signIn(email, password)
if (result.mfaRequired) {
val session = client.auth.mfa.verifyTotp(result.mfaTicket!!, code)
} else {
// Normal login
}
Map<String, Object> result = client.auth().signIn(email, password);
if (Boolean.TRUE.equals(result.get("mfaRequired"))) {
Map<String, Object> session = client.auth().verifyTotp(
result.get("mfaTicket").toString(),
totpCode
);
} else {
// Normal login
}
var result = await client.Auth.SignInAsync(email, password);
if (result.TryGetValue("mfaRequired", out var mfa) && mfa is true) {
var session = await client.Auth.VerifyTotpAsync(
result["mfaTicket"]!.ToString()!,
totpCode
);
} else {
// Normal login
}
auto result = client.auth().signIn(email, password);
auto data = nlohmann::json::parse(result.body);
if (data.value("mfaRequired", false)) {
auto session = client.auth().verifyTotp(
data["mfaTicket"].get<std::string>(),
totpCode
);
} else {
// Normal login
}
Recovery Codes
If a user loses access to their authenticator app, they can use a one-time recovery code:
- JavaScript
- Dart/Flutter
- Java
- C#
- C++
const result = await client.auth.signIn({ email, password });
if (result.mfaRequired) {
// Use recovery code instead of TOTP
const session = await client.auth.mfa.useRecoveryCode(
result.mfaTicket,
recoveryCode,
);
}
if (result.mfaRequired) {
final session = await client.auth.mfa.useRecoveryCode(
result.mfaTicket!,
recoveryCode,
);
}
Map<String, Object> result = client.auth().signIn(email, password);
if (Boolean.TRUE.equals(result.get("mfaRequired"))) {
Map<String, Object> session = client.auth().useRecoveryCode(
result.get("mfaTicket").toString(),
recoveryCode
);
}
var result = await client.Auth.SignInAsync(email, password);
if (result.TryGetValue("mfaRequired", out var mfa) && mfa is true) {
var session = await client.Auth.UseRecoveryCodeAsync(
result["mfaTicket"]!.ToString()!,
recoveryCode
);
}
auto result = client.auth().signIn(email, password);
auto data = nlohmann::json::parse(result.body);
if (data.value("mfaRequired", false)) {
auto session = client.auth().useRecoveryCode(
data["mfaTicket"].get<std::string>(),
recoveryCode
);
}
Each recovery code can only be used once. After all 8 codes are used, only the TOTP authenticator app can be used for MFA. Users should re-enroll to get new recovery codes if they're running low.
Disable MFA
Users can disable MFA by providing their password or a valid TOTP code:
- JavaScript
- Dart/Flutter
- Java
- C#
- C++
// Disable with password
await client.auth.mfa.disableTotp({ password: 'currentPassword' });
// Or disable with TOTP code
await client.auth.mfa.disableTotp({ code: '123456' });
await client.auth.mfa.disableTotp(password: 'currentPassword');
client.auth().disableTotp("currentPassword", null);
// or: client.auth().disableTotp(null, "123456");
await client.Auth.DisableTotpAsync(password: "currentPassword");
// or: await client.Auth.DisableTotpAsync(code: "123456");
client.auth().disableTotp("currentPassword", "");
// or: client.auth().disableTotp("", "123456");
List MFA Factors
Check which MFA methods are enrolled:
- JavaScript
- Java
- C#
- C++
const { factors } = await client.auth.mfa.listFactors();
// [{ id: '...', type: 'totp', verified: true, createdAt: '...' }]
List<Map<String, Object>> factors = client.auth().listFactors();
var result = await client.Auth.ListFactorsAsync();
var factors = result["factors"];
auto result = client.auth().listFactors();
auto factors = nlohmann::json::parse(result.body)["factors"];
Admin: Force-Disable MFA
Admins can disable MFA for any user (e.g., when a user is locked out):
- JavaScript
- Scala
- Elixir
- Python
await adminClient.auth.disableMfa(userId);
admin.auth.disableMfa(userId)
EdgeBaseAdmin.disable_mfa(admin, user_id)
admin.auth.disable_mfa(user_id)
Full React Example
import { useState } from 'react';
import { useEdgeBase } from './edgebase';
function MfaEnrollment() {
const { client } = useEdgeBase();
const [step, setStep] = useState<'idle' | 'enrolled' | 'done'>('idle');
const [enrollData, setEnrollData] = useState<any>(null);
const [code, setCode] = useState('');
const handleEnroll = async () => {
const result = await client.auth.mfa.enrollTotp();
setEnrollData(result);
setStep('enrolled');
};
const handleVerify = async () => {
await client.auth.mfa.verifyTotpEnrollment(enrollData.factorId, code);
setStep('done');
};
if (step === 'idle') {
return <button onClick={handleEnroll}>Enable 2FA</button>;
}
if (step === 'enrolled') {
return (
<div>
<h3>Scan this QR code</h3>
{/* Use a QR code library to render enrollData.qrCodeUri */}
<p>Or enter manually: <code>{enrollData.secret}</code></p>
<h3>Save your recovery codes</h3>
<ul>
{enrollData.recoveryCodes.map((code: string) => (
<li key={code}><code>{code}</code></li>
))}
</ul>
<input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Enter 6-digit code"
maxLength={6}
/>
<button onClick={handleVerify}>Verify</button>
</div>
);
}
return <p>2FA enabled successfully!</p>;
}
function MfaLogin() {
const { client } = useEdgeBase();
const [mfaTicket, setMfaTicket] = useState('');
const [code, setCode] = useState('');
const handleSignIn = async (email: string, password: string) => {
const result = await client.auth.signIn({ email, password });
if (result.mfaRequired) {
setMfaTicket(result.mfaTicket);
// Show MFA code input
}
};
const handleMfaVerify = async () => {
await client.auth.mfa.verifyTotp(mfaTicket, code);
// User is now signed in
};
// ... render login form + MFA code input
}
Security Notes
- TOTP Standard: RFC 6238, SHA-1 HMAC, 6-digit codes, 30-second step
- Clock Tolerance: Codes are accepted within a ±1 step window (90 seconds total)
- Secret Storage: TOTP secrets are encrypted at rest with AES-256-GCM
- Recovery Codes: SHA-256 hashed before storage, 8 codes per enrollment
- MFA Ticket: Expires after 5 minutes (300 seconds), stored in KV
- Rate Limiting: Standard auth rate limits apply to all MFA endpoints
REST API Reference
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST | /api/auth/mfa/totp/enroll | Access Token | Start TOTP enrollment |
POST | /api/auth/mfa/totp/verify | Access Token | Confirm enrollment with code |
POST | /api/auth/mfa/verify | None (mfaTicket) | Complete MFA challenge with TOTP |
POST | /api/auth/mfa/recovery | None (mfaTicket) | Complete MFA with recovery code |
DELETE | /api/auth/mfa/totp | Access Token | Disable TOTP MFA |
GET | /api/auth/mfa/factors | Access Token | List enrolled factors |
DELETE | /api/auth/admin/users/:id/mfa | Service Key | Admin: force-disable MFA |
Error Responses
| Status | Code | When |
|---|---|---|
400 | Invalid TOTP code | Wrong 6-digit code during enrollment verification |
400 | mfaTicket/code required | Missing required fields |
400 | Invalid or expired MFA ticket | Ticket not found or expired (5 min TTL) |
401 | Invalid TOTP code | Wrong code during sign-in MFA challenge |
401 | Invalid recovery code | Wrong or already-used recovery code |
401 | Invalid password | Wrong password when disabling MFA |
404 | TOTP MFA is not enabled | auth.mfa.totp not set in config |
409 | TOTP factor already enrolled | User already has active TOTP |