Skip to main content

Multi-Factor Authentication (MFA)

Add an extra layer of security with TOTP-based multi-factor authentication. Users enroll using any authenticator app (Google Authenticator, Authy, 1Password, etc.).

Client vs Admin

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

  1. Enrollment: User enrolls TOTP → gets a secret (QR code) + 8 recovery codes
  2. Verification: User enters a 6-digit code from their authenticator app to confirm enrollment
  3. Login: After password verification, user must provide a TOTP code to complete sign-in
  4. 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.

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

Step 2: Verify Enrollment

After the user scans the QR code and enters a 6-digit code from their authenticator app:

await client.auth.mfa.verifyTotpEnrollment(factorId, code);
// MFA is now active for this user

Login with MFA

When MFA is enabled, signIn returns a challenge instead of session tokens:

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
}

Recovery Codes

If a user loses access to their authenticator app, they can use a one-time recovery code:

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,
);
}
Recovery codes are single-use

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:

// Disable with password
await client.auth.mfa.disableTotp({ password: 'currentPassword' });

// Or disable with TOTP code
await client.auth.mfa.disableTotp({ code: '123456' });

List MFA Factors

Check which MFA methods are enrolled:

const { factors } = await client.auth.mfa.listFactors();
// [{ id: '...', type: 'totp', verified: true, createdAt: '...' }]

Admin: Force-Disable MFA

Admins can disable MFA for any user (e.g., when a user is locked out):

await adminClient.auth.disableMfa(userId);

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

MethodEndpointAuthDescription
POST/api/auth/mfa/totp/enrollAccess TokenStart TOTP enrollment
POST/api/auth/mfa/totp/verifyAccess TokenConfirm enrollment with code
POST/api/auth/mfa/verifyNone (mfaTicket)Complete MFA challenge with TOTP
POST/api/auth/mfa/recoveryNone (mfaTicket)Complete MFA with recovery code
DELETE/api/auth/mfa/totpAccess TokenDisable TOTP MFA
GET/api/auth/mfa/factorsAccess TokenList enrolled factors
DELETE/api/auth/admin/users/:id/mfaService KeyAdmin: force-disable MFA

Error Responses

StatusCodeWhen
400Invalid TOTP codeWrong 6-digit code during enrollment verification
400mfaTicket/code requiredMissing required fields
400Invalid or expired MFA ticketTicket not found or expired (5 min TTL)
401Invalid TOTP codeWrong code during sign-in MFA challenge
401Invalid recovery codeWrong or already-used recovery code
401Invalid passwordWrong password when disabling MFA
404TOTP MFA is not enabledauth.mfa.totp not set in config
409TOTP factor already enrolledUser already has active TOTP