Multi-Factor Authentication (MFA)¶
Secure accounts with TOTP-based two-factor authentication.
Overview¶
AuthVital supports TOTP-based MFA (Time-based One-Time Passwords) compatible with: - Google Authenticator - Authy - 1Password - Microsoft Authenticator - Any TOTP-compatible app
MFA Policies¶
Instance-Level¶
| Setting | Description |
|---|---|
superAdminMfaRequired | Require MFA for all super admins |
Tenant-Level¶
| Policy | Description |
|---|---|
DISABLED | MFA is disabled for the tenant |
OPTIONAL | MFA available but not required |
ENCOURAGED | MFA recommended, users see prompts to enable |
REQUIRED | All members must enable MFA (grace period configurable via mfaGracePeriodDays) |
User MFA Flow¶
Enabling MFA¶
sequenceDiagram
participant U as User
participant A as App
participant AV as AuthVital
U->>A: Click "Enable MFA"
A->>AV: POST /api/mfa/setup
AV->>A: { secret, qrCodeUrl, backupCodes }
A->>U: Show QR code
U->>U: Scan with authenticator app
U->>A: Enter TOTP code
A->>AV: POST /api/mfa/verify { code }
AV->>A: { success: true }
A->>U: MFA enabled! Save backup codes Authentication with MFA¶
sequenceDiagram
participant U as User
participant A as App
participant AV as AuthVital
U->>A: Login (email, password)
A->>AV: POST /api/auth/login
AV->>A: { mfaRequired: true, mfaChallengeToken }
A->>U: Show MFA challenge
U->>A: Enter TOTP code
A->>AV: POST /api/mfa/challenge { token, code }
AV->>A: { accessToken, refreshToken }
A->>U: Login successful! SDK Methods¶
Setup MFA¶
// Start MFA setup
const setup = await authvital.mfa.setup(req);
// {
// secret: "JBSWY3DPEHPK3PXP",
// qrCodeUrl: "data:image/png;base64,...",
// otpauthUrl: "otpauth://totp/MyApp:user@example.com?secret=...",
// backupCodes: ["12345678", "87654321", ...]
// }
Backup Code Lifecycle
Backup codes are NOT active until MFA setup is verified.
POST /mfa/setup- Returns secret + backup codes (codes are pending)- User scans QR code and enters TOTP code
POST /mfa/verify- Validates TOTP code and activates MFA + backup codes- If setup is abandoned, pending backup codes are discarded
This prevents an attacker who intercepts the setup response from using backup codes before the legitimate user completes enrollment.
Verify MFA Setup¶
// Complete MFA setup with TOTP code
const result = await authvital.mfa.verifySetup(req, {
code: '123456', // 6-digit code from authenticator
});
// { success: true, mfaEnabled: true }
Complete MFA Challenge¶
// After login returns mfaRequired: true
const tokens = await authvital.mfa.verifyChallenge({
challengeToken: 'challenge-token-from-login',
code: '123456', // TOTP code
});
// { accessToken, refreshToken, idToken }
Disable MFA¶
// User must verify current code to disable
const result = await authvital.mfa.disable(req, {
code: '123456', // Current TOTP code
});
// { success: true, mfaEnabled: false }
Use Backup Code¶
// When user can't access authenticator
const tokens = await authvital.mfa.useBackupCode({
challengeToken: 'challenge-token-from-login',
backupCode: '12345678',
});
// { accessToken, refreshToken, idToken }
// Note: Each backup code can only be used once
Regenerate Backup Codes¶
// Get new backup codes (invalidates old ones)
const { backupCodes } = await authvital.mfa.regenerateBackupCodes(req, {
code: '123456', // Current TOTP code for verification
});
// backupCodes: ["new-code-1", "new-code-2", ...]
React Integration¶
Important: MFA setup and verification are server-side operations. Your React components should call YOUR backend API, which then uses the AuthVital server SDK.
MFA Setup Component¶
import { useState } from 'react';
// NOTE: MFA operations require server-side API calls.
// Your React component should call YOUR backend API,
// which then uses the server SDK:
//
// Server-side (your API route):
// const result = await authvital.mfa.setup(request);
// const verified = await authvital.mfa.verifySetup(request, { code });
function MfaSetup() {
const [setup, setSetup] = useState(null);
const [code, setCode] = useState('');
const [backupCodes, setBackupCodes] = useState([]);
const handleStartSetup = async () => {
// Call YOUR backend API that uses the server SDK
const response = await fetch('/api/mfa/setup', { method: 'POST' });
const result = await response.json();
setSetup(result);
};
const handleVerify = async () => {
// Call YOUR backend API that uses the server SDK
const response = await fetch('/api/mfa/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code }),
});
const result = await response.json();
if (result.success) {
setBackupCodes(setup.backupCodes);
}
};
if (backupCodes.length > 0) {
return (
<div>
<h2>✅ MFA Enabled!</h2>
<p>Save these backup codes in a safe place:</p>
<ul>
{backupCodes.map(code => (
<li key={code}><code>{code}</code></li>
))}
</ul>
<p>⚠️ Each code can only be used once.</p>
</div>
);
}
if (setup) {
return (
<div>
<h2>Scan QR Code</h2>
<img src={setup.qrCodeUrl} alt="MFA QR Code" />
<p>Or enter manually: <code>{setup.secret}</code></p>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Enter 6-digit code"
maxLength={6}
/>
<button onClick={handleVerify}>Verify</button>
</div>
);
}
return (
<div>
<h2>Enable Two-Factor Authentication</h2>
<p>Add an extra layer of security to your account.</p>
<button onClick={handleStartSetup}>Set up MFA</button>
</div>
);
}
MFA Challenge Component¶
// NOTE: MFA verification is a server-side operation.
// Your component should call YOUR backend API:
//
// Server-side (your API route):
// const result = await authvital.mfa.verifyChallenge(request, { code, challengeToken });
// const result = await authvital.mfa.useBackupCode(request, { code, challengeToken });
function MfaChallenge({ challengeToken, onSuccess }) {
const [code, setCode] = useState('');
const [useBackup, setUseBackup] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
try {
// Call YOUR backend API that uses the server SDK
const response = await fetch('/api/mfa/challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, challengeToken, useBackup }),
});
if (!response.ok) throw new Error('Verification failed');
onSuccess();
} catch (err) {
setError('Invalid code. Please try again.');
}
};
return (
<form onSubmit={handleSubmit}>
<h2>Two-Factor Authentication</h2>
{!useBackup ? (
<>
<p>Enter the 6-digit code from your authenticator app:</p>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="123456"
maxLength={6}
autoFocus
/>
</>
) : (
<>
<p>Enter one of your backup codes:</p>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="12345678"
maxLength={8}
/>
</>
)}
{error && <p className="error">{error}</p>}
<button type="submit">Verify</button>
<button
type="button"
onClick={() => setUseBackup(!useBackup)}
>
{useBackup ? 'Use authenticator app' : 'Use backup code'}
</button>
</form>
);
}
Login with MFA Handling¶
import { useAuth } from '@authvital/sdk/client';
function Login() {
const { login } = useAuth();
const [mfaChallenge, setMfaChallenge] = useState(null);
const navigate = useNavigate();
const handleLogin = async (email, password) => {
const result = await login(email, password);
if (result.mfaRequired) {
setMfaChallenge(result.mfaChallengeToken);
} else {
navigate('/dashboard');
}
};
if (mfaChallenge) {
return (
<MfaChallenge
challengeToken={mfaChallenge}
onSuccess={() => navigate('/dashboard')}
/>
);
}
return <LoginForm onSubmit={handleLogin} />;
}
Admin Configuration¶
Set Tenant MFA Policy¶
// Require MFA for all tenant members
await authvital.tenants.update('tenant-id', {
mfaPolicy: 'REQUIRED',
});
// Require MFA with a grace period
await authvital.tenants.update('tenant-id', {
mfaPolicy: 'REQUIRED',
mfaGracePeriodDays: 14, // Users have 14 days to enable MFA
});
Require MFA for Super Admins¶
Check User MFA Status¶
// Get MFA status for a user
const status = await authvital.mfa.getStatus(userId);
// Returns: { mfaEnabled: boolean, mfaVerifiedAt: string | null, backupCodesRemaining: number }
// In JWT, check mfa_enabled claim for quick checks
if (!user.mfa_enabled && tenantPolicy === 'REQUIRED') {
// Redirect to MFA setup
}
Backup Codes¶
- 10 codes generated during MFA setup
- Each code is 8 characters
- One-time use - codes are invalidated after use
- Can be regenerated (invalidates all old codes)
- Stored as bcrypt hashes (secure even if database compromised)
Best Practices for Users¶
- Save codes securely - Password manager, safe, printed copy
- Don't store digitally - Unless encrypted
- Regenerate periodically - Especially if codes may be compromised
- Know where they are - Before traveling or changing phones
Security Considerations¶
✅ Best Practices¶
- Require MFA for admins - Higher privilege = higher security
- Use grace periods wisely - Give users time to set up
- Monitor MFA events - Track setup, disable, backup code usage
- Provide backup code guidance - Users often lose access
Token Security¶
MFA challenge tokens: - Short-lived (5 minutes) - Single-use - Tied to specific user and session - Invalidated after successful verification
Recovery Options¶
If user loses access to authenticator AND backup codes:
- Identity verification - Manual process via support
- Admin reset - Super admin can disable user's MFA
- Account recovery flow - Verify via email + additional checks
// Admin: Disable user's MFA (emergency only)
// This operation is available via the Admin UI or direct API call.
// SDK method coming soon - for now use:
// Option 1: Admin UI
// Navigate to Users → [User] → Security → Disable MFA
// Option 2: Direct API call (requires super admin token)
await fetch(`${process.env.AV_HOST}/api/admin/users/${userId}/mfa`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${adminToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
reason: 'User lost access to authenticator',
}),
});
Webhook Events¶
MFA Webhook Events - Coming Soon
MFA-specific webhook events (mfa.enabled, mfa.disabled, mfa.backup_used, mfa.backup_regenerated) are planned but not yet implemented.
For now, you can:
- Monitor via audit logs - Check the AuthVital admin panel for MFA events
- Implement in your app - Log MFA changes when your backend calls MFA SDK methods
// Example: Log MFA events in your backend
app.post('/api/mfa/verify', async (req, res) => {
const result = await authvital.mfa.verifySetup(req, { code: req.body.code });
if (result.success) {
// Log the MFA enablement in your own audit system
await auditLog.create({
action: 'mfa_enabled',
userId: req.user.sub,
timestamp: new Date(),
});
}
res.json(result);
});
Troubleshooting¶
"Invalid code"¶
- Clock sync - TOTP requires synchronized clocks (±30 seconds)
- Wrong account - User may have multiple accounts in authenticator
- Code expired - Codes change every 30 seconds
"MFA required but not enabled"¶
User in a tenant with REQUIRED policy but hasn't set up MFA: - Redirect to MFA setup flow - Block access until MFA is enabled
Lost Authenticator¶
- Use backup code
- If no backup codes, contact admin
- Admin can disable MFA for account recovery