The "mfa_verification_failed: MFA verification failed" error occurs when Supabase Auth cannot verify a multi-factor authentication code. This typically happens due to incorrect TOTP codes, timing synchronization issues, expired codes, or rate limiting from too many failed verification attempts.
The "mfa_verification_failed" error is a Supabase Auth error code that indicates the multi-factor authentication (MFA) verification process has failed. This error occurs during the sign-in flow when a user has MFA enabled and attempts to verify their identity with a time-based one-time password (TOTP) from an authenticator app or a code sent via SMS/WhatsApp. When Supabase Auth receives an MFA verification request through the `supabase.auth.mfa.verify()` API, it compares the submitted code against the expected code generated by the user's enrolled factor. If the codes don't match, the verification fails and returns this error. The verification can fail for several reasons: incorrect code entry, time synchronization issues between the server and authenticator app, expired codes, or security measures like rate limiting that block excessive verification attempts. This error is particularly important because it prevents users from completing authentication, even if they've successfully entered their primary credentials (email/password). Understanding the root cause is essential for maintaining a smooth user authentication experience while preserving security.
First, ensure the user is looking at the correct TOTP code:
1. Open your authenticator app (Google Authenticator, Authy, Microsoft Authenticator, etc.)
2. Find the correct entry for your Supabase application
3. Verify the account name matches your logged-in user
4. Ensure you're not looking at a different account's code
If you have multiple accounts or MFA factors enrolled, it's easy to enter the wrong code. Double-check the account label in your authenticator app.
TOTP codes expire every 30 seconds, and timing is critical:
1. Watch the timer in your authenticator app (usually a circular countdown)
2. Wait for a new code to appear (don't use a code that's about to expire)
3. Enter the code immediately after it appears
4. Submit within 5-10 seconds for best results
A known issue with Supabase MFA is that codes may be rejected if more than 3 seconds have passed since the code changed. For best results, enter the code as soon as it appears in your authenticator app.
// Example: Proper MFA verification flow
const { data, error } = await supabase.auth.mfa.verify({
factorId: factorId,
challengeId: challengeId,
code: '123456' // Enter fresh code immediately after generation
});
if (error) {
console.error('MFA verification failed:', error.message);
// Prompt user to try again with a fresh code
}TOTP relies on accurate time synchronization between the server and your device:
For iOS:
1. Go to Settings → General → Date & Time
2. Enable "Set Automatically"
3. Restart your authenticator app
For Android:
1. Go to Settings → System → Date & time
2. Enable "Use network-provided time"
3. Open Google Authenticator → Settings → Time correction for codes → Sync now
For Desktop:
- Ensure your system clock is set to automatic/network time
- Verify your timezone is correct
Even a few seconds of time drift can cause TOTP verification to fail. Most authenticator apps require time synchronization within 30-90 seconds of the server time.
If you've had multiple failed attempts, rate limiting may be blocking verification:
1. Wait 1-2 minutes before trying again to allow rate limits to reset
2. Close and reopen your authenticator app
3. Generate a new challenge before verifying:
// Start fresh with a new challenge
async function retryMfaVerification(factorId) {
try {
// Step 1: Create a new challenge
const { data: challengeData, error: challengeError } =
await supabase.auth.mfa.challenge({ factorId });
if (challengeError) {
console.error('Challenge creation failed:', challengeError);
return;
}
// Step 2: Wait for user to enter fresh TOTP code
const totpCode = prompt('Enter 6-digit code from authenticator app:');
// Step 3: Verify with the new challenge
const { data, error } = await supabase.auth.mfa.verify({
factorId: factorId,
challengeId: challengeData.id,
code: totpCode
});
if (error) {
console.error('Verification failed:', error.message);
// Suggest waiting and trying again
} else {
console.log('MFA verification successful!');
}
} catch (err) {
console.error('MFA retry failed:', err);
}
}If verification continues to fail, the MFA factor may need to be re-enrolled.
If verification continues to fail despite correct codes, re-enroll the MFA factor:
// Step 1: Unenroll the existing factor (requires authentication)
async function reenrollMfa() {
// Get existing factors
const { data: factors } = await supabase.auth.mfa.listFactors();
// Unenroll problematic factor
const problematicFactor = factors.totp[0]; // Or identify the specific factor
const { error: unenrollError } = await supabase.auth.mfa.unenroll({
factorId: problematicFactor.id
});
if (unenrollError) {
console.error('Unenroll failed:', unenrollError);
return;
}
// Step 2: Enroll a new factor
const { data: enrollData, error: enrollError } =
await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'My Authenticator App'
});
if (enrollError) {
console.error('Enrollment failed:', enrollError);
return;
}
// Step 3: Display QR code to user
const qrCode = enrollData.totp.qr_code;
const secret = enrollData.totp.secret;
console.log('Scan this QR code with your authenticator app:');
console.log(qrCode);
console.log('Or manually enter secret:', secret);
// Step 4: Verify the newly enrolled factor
const totpCode = prompt('Enter code from authenticator app:');
const { data: challengeData } = await supabase.auth.mfa.challenge({
factorId: enrollData.id
});
const { error: verifyError } = await supabase.auth.mfa.verify({
factorId: enrollData.id,
challengeId: challengeData.id,
code: totpCode
});
if (verifyError) {
console.error('New factor verification failed:', verifyError);
} else {
console.log('MFA re-enrollment successful!');
}
}Important: Make sure to delete the old entry from your authenticator app and scan the new QR code.
For production applications, implement MFA verification hooks to handle failures gracefully:
// Example: MFA verification with comprehensive error handling
async function secureMfaVerification(factorId, challengeId, code) {
const MAX_ATTEMPTS = 3;
const LOCKOUT_DURATION = 60000; // 1 minute
try {
const { data, error } = await supabase.auth.mfa.verify({
factorId,
challengeId,
code
});
if (error) {
// Track failed attempts
const attempts = incrementFailedAttempts(factorId);
if (attempts >= MAX_ATTEMPTS) {
return {
success: false,
error: 'Too many failed attempts. Please wait 1 minute and try again.',
lockout: true
};
}
return {
success: false,
error: 'Invalid code. Please try again with a fresh code.',
attemptsRemaining: MAX_ATTEMPTS - attempts
};
}
// Reset failed attempts on success
clearFailedAttempts(factorId);
return {
success: true,
data: data
};
} catch (err) {
console.error('MFA verification exception:', err);
return {
success: false,
error: 'An unexpected error occurred. Please try again.'
};
}
}
// Helper functions for rate limiting (implement with Redis or in-memory store)
function incrementFailedAttempts(factorId) {
// Store in Redis or session storage
// Return current count
}
function clearFailedAttempts(factorId) {
// Clear stored attempts
}You can also implement server-side MFA verification hooks using Supabase Edge Functions for centralized rate limiting and logging.
## Deep Dive: TOTP and MFA Security in Supabase
### How TOTP Works
Time-based One-Time Passwords (TOTP) use a shared secret and the current time to generate codes:
1. Shared Secret: During enrollment, Supabase generates a secret key shared between the server and the user's authenticator app
2. Time Window: Codes are generated in 30-second windows
3. Algorithm: Uses HMAC-SHA1 to combine the secret and current time
4. Validation Window: Supabase accepts codes from the current window and typically 1 window before/after (90 seconds total)
### Known Timing Issues
Supabase has a documented timing sensitivity where codes may be rejected if more than 3 seconds have passed since generation. This is stricter than the standard TOTP specification and can cause user frustration. The recommended workaround is to enter codes immediately after they appear in the authenticator app.
### MFA Verification Hooks
Supabase provides MFA verification hooks (server-side functions) that allow you to:
- Rate Limiting: Limit verification attempts to prevent brute-force attacks
- Logging: Track all MFA verification attempts for security auditing
- Custom Logic: Implement additional checks (device fingerprinting, location verification)
- User Lockout: Temporarily lock accounts after too many failed attempts
Example hook configuration:
-- Create an Edge Function for MFA verification hook
create function public.handle_mfa_verification()
returns trigger as $$
begin
-- Log the verification attempt
insert into mfa_logs (user_id, factor_id, success, timestamp)
values (new.user_id, new.factor_id, new.success, now());
-- Implement rate limiting logic
-- Block if more than 5 failed attempts in last 5 minutes
return new;
end;
$$ language plpgsql security definer;### Security Best Practices
1. Backup Factors: Always enroll at least two MFA factors (e.g., two authenticator apps or one authenticator + backup codes)
2. Recovery Options: Supabase doesn't provide recovery codes by default, so implement your own recovery mechanism
3. Account Recovery: Plan for scenarios where users lose access to their authenticator app (support process, identity verification)
4. Factor Management: Allow users to manage their enrolled factors through a settings page
### Phone MFA vs TOTP MFA
Supabase supports two types of MFA:
| Factor Type | Pros | Cons |
|------------|------|------|
| TOTP (Authenticator App) | More secure, works offline, no per-message cost | Requires app installation, time sync issues |
| Phone (SMS/WhatsApp) | Easier for users, no app required | Costs per SMS, carrier dependency, less secure |
The mfa_verification_failed error applies to both types, but the troubleshooting steps differ slightly for phone-based MFA.
### Debugging MFA Issues
Enable detailed logging to diagnose persistent MFA issues:
1. Client-side logging:
supabase.auth.onAuthStateChange((event, session) => {
console.log('Auth event:', event);
if (event === 'MFA_CHALLENGE_VERIFIED') {
console.log('MFA verified successfully');
}
});2. Check Supabase Dashboard: Authentication → Logs → Filter for "mfa_verification_failed"
3. Error details: The error response includes status, code, and sometimes provider-specific details
### Unverified Factor Accumulation
A known issue is that unverified MFA factors can accumulate when users start enrollment but don't complete it. This can cause confusion during authentication. Clean up unverified factors periodically:
// List all factors
const { data: factors } = await supabase.auth.mfa.listFactors();
// Find unverified factors
const unverified = factors.all.filter(f => f.status !== 'verified');
// Unenroll them
for (const factor of unverified) {
await supabase.auth.mfa.unenroll({ factorId: factor.id });
}email_address_not_authorized: Email sending to this address is not authorized
Email address not authorized for sending in Supabase Auth
reauthentication_needed: Reauthentication required for security-sensitive actions
Reauthentication required for security-sensitive actions
no_authorization: No authorization header was provided
How to fix "no authorization header was provided" in Supabase
otp_expired: OTP has expired
How to fix 'otp_expired: OTP has expired' in Supabase
bad_oauth_state: OAuth state parameter is missing or invalid
How to fix 'bad_oauth_state: OAuth state parameter missing' in Supabase