The "mfa_factor_not_found" error in Supabase occurs when attempting to challenge, verify, or manage an MFA factor that doesn't exist or is no longer available. This typically happens during incomplete enrollments, page refreshes before verification completes, or when trying to use an unenrolled factor ID.
The "mfa_factor_not_found" error is a Supabase Auth error that indicates the MFA factor you're trying to access, challenge, or verify does not exist in the system for the currently authenticated user. This error occurs when the `challengeAndVerify()`, `challenge()`, `verify()`, or `unenroll()` API methods receive a factor ID that either: 1. Was never successfully enrolled 2. Was already unenrolled by the user 3. Belongs to a different user 4. Was lost due to session state issues or page refreshes during enrollment In most cases, this error appears during the MFA enrollment flow when the QR code and secret are displayed to the user, but the enrollment is not completed and verified before the page refreshes or times out. The unverified factor becomes orphaned and cannot be challenged or verified later. Supabase has a known limitation where unverified factors can accumulate up to a limit of 10 per user, blocking new factor enrollment attempts if the limit is reached. Understanding how factors are created, verified, and managed is critical for implementing a robust MFA enrollment experience.
Always list existing factors before starting the enrollment process to understand the current state:
async function checkExistingFactors() {
try {
const { data: factors, error } = await supabase.auth.mfa.listFactors();
if (error) {
console.error('Failed to list factors:', error);
return;
}
console.log('TOTP factors:', factors.totp);
console.log('Phone factors:', factors.phone);
console.log('All factors:', factors.all);
// Check for unverified factors
const unverified = factors.all.filter(f => f.status !== 'verified');
console.log('Unverified factors:', unverified.length);
return factors;
} catch (err) {
console.error('Error listing factors:', err);
}
}
// Call this before enrollment
const currentFactors = await checkExistingFactors();This helps you:
- Understand how many factors the user already has
- Identify unverified factors that may be blocking new enrollments
- Prevent accidental re-enrollment of the same factor
If listFactors() shows unverified factors or you've hit the 10-factor limit, remove unverified factors before starting new enrollment:
async function cleanupUnverifiedFactors() {
try {
const { data: factors, error } = await supabase.auth.mfa.listFactors();
if (error) {
console.error('Failed to list factors:', error);
return;
}
// Find unverified factors
const unverified = factors.all.filter(f => f.status !== 'verified');
if (unverified.length === 0) {
console.log('No unverified factors to clean up');
return;
}
console.log('Cleaning up', unverified.length, 'unverified factors...');
// Unenroll each unverified factor
for (const factor of unverified) {
const { error: unenrollError } = await supabase.auth.mfa.unenroll({
factorId: factor.id
});
if (unenrollError) {
console.error('Failed to unenroll factor', factor.id, ':', unenrollError);
} else {
console.log('Unenrolled factor:', factor.id);
}
}
console.log('Cleanup complete');
} catch (err) {
console.error('Error cleaning up factors:', err);
}
}
// Call before attempting new enrollment
await cleanupUnverifiedFactors();This prevents the "factor not found" error caused by orphaned unverified factors.
Ensure the complete enrollment flow is done in a single session without page refreshes:
async function completeMfaEnrollment() {
try {
// Step 1: Initiate enrollment
const { data: enrollData, error: enrollError } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'My Authenticator App'
});
if (enrollError) {
console.error('Enrollment failed:', enrollError.message);
return;
}
console.log('Enrollment initiated. Factor ID:', enrollData.id);
// IMPORTANT: Store the factor ID and secret temporarily
// Do NOT reload the page or lose this session
// Step 2: 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 enter this secret:', secret);
// Step 3: Wait for user to scan and get a code
// This MUST happen in the same session without page reload
const userCode = prompt('Enter the 6-digit code from your authenticator app:');
if (!userCode) {
console.error('User cancelled enrollment');
// Clean up the unverified factor
await supabase.auth.mfa.unenroll({ factorId: enrollData.id });
return;
}
// Step 4: Create a challenge for verification
const { data: challengeData, error: challengeError } =
await supabase.auth.mfa.challenge({ factorId: enrollData.id });
if (challengeError) {
console.error('Challenge creation failed:', challengeError.message);
return;
}
// Step 5: Verify the enrollment
const { data: verifyData, error: verifyError } = await supabase.auth.mfa.verify({
factorId: enrollData.id,
challengeId: challengeData.id,
code: userCode
});
if (verifyError) {
console.error('Verification failed:', verifyError.message);
// Clean up the unverified factor
await supabase.auth.mfa.unenroll({ factorId: enrollData.id });
return;
}
console.log('MFA enrollment successful!');
console.log('Session updated:', verifyData);
} catch (err) {
console.error('MFA enrollment exception:', err);
}
}Key points:
- Do NOT refresh the page during enrollment
- Keep the factor ID available throughout the flow
- Clean up on error to prevent orphaned factors
For TOTP factors, use the challengeAndVerify() helper which combines challenge and verify:
async function simpleMfaEnrollment() {
try {
// Step 1: Enroll the factor
const { data: enrollData, error: enrollError } = await supabase.auth.mfa.enroll({
factorType: 'totp',
friendlyName: 'My Authenticator'
});
if (enrollError) {
console.error('Enrollment failed:', enrollError);
return;
}
// Step 2: Display QR code
console.log('QR Code:', enrollData.totp.qr_code);
const userCode = prompt('Enter code from authenticator:');
// Step 3: Use challengeAndVerify for one-step verification
const { data, error } = await supabase.auth.mfa.challengeAndVerify({
factorId: enrollData.id,
code: userCode
});
if (error) {
console.error('Verification failed:', error.message);
// Clean up unverified factor
await supabase.auth.mfa.unenroll({ factorId: enrollData.id });
return;
}
console.log('MFA enrollment complete!');
console.log('Session:', data.session);
} catch (err) {
console.error('Error:', err);
}
}The challengeAndVerify() helper is simpler and less error-prone than the two-step approach.
Handle "factor not found" errors gracefully with retry logic and cleanup:
async function robustMfaVerification(factorId, code) {
try {
// First, verify the factor still exists
const { data: factors, error: listError } = await supabase.auth.mfa.listFactors();
if (listError) {
throw new Error('Failed to list factors: ' + listError.message);
}
const factorExists = factors.all.some(f => f.id === factorId);
if (!factorExists) {
console.error('Factor not found. Available factors:', factors.all.length);
// If no factors available, suggest re-enrollment
if (factors.all.length === 0) {
throw new Error('No MFA factors found. Please enroll again.');
}
// If other factors exist, suggest using them
throw new Error('The selected factor no longer exists. Please select another factor or re-enroll.');
}
// Proceed with verification
const { data: challengeData, error: challengeError } =
await supabase.auth.mfa.challenge({ factorId });
if (challengeError) {
console.error('Challenge failed:', challengeError.message);
throw challengeError;
}
const { data, error } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code
});
if (error) {
console.error('Verification failed:', error.message);
throw error;
}
return { success: true, data };
} catch (err) {
console.error('MFA verification error:', err.message);
return {
success: false,
error: err.message,
needsReenroll: err.message.includes('not found')
};
}
}This pattern:
- Checks if the factor exists before attempting verification
- Provides clear error messages to users
- Indicates when re-enrollment is needed
If you need to split enrollment across multiple pages, store factor state securely:
// Using localStorage (for non-sensitive development)
async function startEnrollmentWithPersistence() {
const { data: enrollData, error } = await supabase.auth.mfa.enroll({
factorType: 'totp'
});
if (error) {
console.error('Enrollment failed:', error);
return;
}
// Store temporarily (clear after verification or timeout)
sessionStorage.setItem('mfa_enrollment', JSON.stringify({
factorId: enrollData.id,
secret: enrollData.totp.secret,
qrCode: enrollData.totp.qr_code,
timestamp: Date.now()
}));
// Navigate to confirmation page
window.location.href = '/mfa/confirm';
}
// On the confirmation page
async function completeEnrollmentWithRetrievedState() {
const stored = sessionStorage.getItem('mfa_enrollment');
if (!stored) {
console.error('Enrollment state lost. Please start again.');
return;
}
const { factorId, timestamp } = JSON.parse(stored);
// Check timeout (e.g., 5 minutes)
if (Date.now() - timestamp > 5 * 60 * 1000) {
console.error('Enrollment session expired. Please start again.');
sessionStorage.removeItem('mfa_enrollment');
return;
}
const userCode = prompt('Enter code from authenticator:');
const { data, error } = await supabase.auth.mfa.challengeAndVerify({
factorId,
code: userCode
});
if (error) {
console.error('Verification failed:', error);
// Keep state for retry
return;
}
// Clear state on success
sessionStorage.removeItem('mfa_enrollment');
console.log('Enrollment complete!');
}Use sessionStorage instead of localStorage to avoid persisting sensitive data across browser restarts.
## Deep Dive: MFA Factor Management in Supabase
### Factor Lifecycle
MFA factors go through distinct states:
1. Unverified (Transient): Created by enroll(), must be verified within the same session
2. Verified (Persistent): Successfully verified and active for authentication
3. Unenrolled: Deleted by user, cannot be used anymore
The "factor not found" error occurs when code tries to access an unverified or unenrolled factor.
### Why Unverified Factors Accumulate
When a user starts enrollment with enroll(), a new unverified factor is created immediately. If they:
- Close the browser
- Refresh the page before verify()
- Abandon the flow midway
- Lose network connectivity
...the factor remains unverified in the database and counts against the 10-factor limit.
Known Issue: Previous Supabase versions didn't automatically clean up unverified factors. Recent updates added auto-deletion of unverified factors, but old orphaned factors may still exist in legacy databases.
### The 10-Factor Limit
Supabase enforces a hard limit of 10 MFA factors per user. This includes both verified AND unverified factors. When the limit is reached:
- New enroll() calls fail with an error
- Users cannot add new MFA methods
- The only solution is to clean up unverified factors with unenroll()
### Factor ID Format and Validity
Factor IDs are UUIDs generated by Supabase Auth. They:
- Are unique per factor
- Do not change once created
- Are only valid for the user who enrolled them
- Cannot be reused after unenrollment
If you see "factor not found" with a UUID you've used before, that factor was likely unenrolled.
### Session State and Factor Verification
MFA verification is session-aware:
// This fails because factor was created in a different session
const { data: enrollData } = await supabase.auth.mfa.enroll({ factorType: 'totp' });
// [Logout/Login happens here]
const { error } = await supabase.auth.mfa.verify({
factorId: enrollData.id, // Now invalid - different session
challengeId: '...',
code: '123456'
});Always complete the full enrollment-to-verification flow in a single authenticated session.
### Phone vs TOTP Factor Differences
| Aspect | TOTP | Phone |
|--------|------|-------|
| Enrollment | Requires user to scan QR code and provide verification code | Sends code via SMS/WhatsApp, user provides it |
| Verification | Uses codes from authenticator app | Sends new code via SMS each time |
| "Factor Not Found" | Often due to incomplete enrollment | More likely from session loss/timeout |
| Recovery | Enroll second TOTP as backup | Enroll TOTP factor as backup |
### Debugging Factor Issues
Enable detailed logging to diagnose factor problems:
// Comprehensive factor diagnostics
async function diagnosticMfaStatus() {
const { data: { user } } = await supabase.auth.getUser();
console.log('Authenticated user:', user?.id);
const { data: factors, error } = await supabase.auth.mfa.listFactors();
if (error) {
console.error('Failed to list factors:', error);
return;
}
console.log('=== MFA Factor Status ===');
console.log('Total factors:', factors.all.length);
console.log('TOTP factors:', factors.totp.length);
console.log('Phone factors:', factors.phone.length);
factors.all.forEach((factor, i) => {
console.log(`\nFactor ${i + 1}:`, {
id: factor.id,
type: factor.factor_type,
status: factor.status,
createdAt: factor.created_at,
updatedAt: factor.updated_at,
friendlyName: factor.friendly_name
});
});
if (factors.all.length >= 10) {
console.warn('WARNING: At factor limit (10). Cannot add more factors.');
}
const unverified = factors.all.filter(f => f.status !== 'verified');
if (unverified.length > 0) {
console.warn('Found', unverified.length, 'unverified factors that should be cleaned up');
}
}
// Run this to understand current state
await diagnosticMfaStatus();### Best Practices for Reliable MFA
1. Always list factors first: Check current state before attempting operations
2. Clean up on error: Unenroll unverified factors if verification fails
3. Set timeouts: If enrollment isn't completed within 5-10 minutes, auto-cleanup
4. Use challengeAndVerify: Simpler than the two-step challenge+verify flow
5. Implement account recovery: Have a process for users who lose all MFA access
6. Monitor factor churn: Track enrollment success/failure rates to catch UX issues
7. Document user responsibilities: Tell users not to scan multiple QR codes for the same factor
### Common "Factor Not Found" Scenarios
| Scenario | Cause | Solution |
|----------|-------|----------|
| Enrollment started, page refreshed before verify | Unverified factor lost | List factors and clean up orphaned ones |
| User logs out during verification | Session ended | Start enrollment from scratch |
| Mobile app crashes during setup | Partial enrollment left behind | Clean up on app restart |
| Using wrong factor ID from cache | Old/wrong factor ID | Always get fresh factor list before use |
| User deleted factor via dashboard | Factor unenrolled | Suggest user re-enroll |
| Hitting 10-factor limit | Too many unverified factors | Clean up unverified ones first |
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