The "mfa_challenge_expired: MFA challenge has expired" error occurs when you attempt to verify a multi-factor authentication challenge that has exceeded its validity window. Supabase challenges have a limited lifetime (typically 5-10 minutes), and verification requests submitted after expiration are rejected, requiring you to create a new challenge.
The "mfa_challenge_expired" error is a Supabase Auth error code that indicates an MFA verification attempt has been made with an expired challenge. When a user initiates MFA authentication by calling `supabase.auth.mfa.challenge()`, Supabase generates a challenge ID that is valid for a specific duration—usually 5 to 10 minutes. If the user takes too long to retrieve their MFA code (from an authenticator app or SMS) and submit the verification, or if the challenge ID is reused after this time window has passed, the verification fails with this error. This is a security measure to prevent replay attacks and ensure that MFA codes are used promptly. The error typically occurs in one of two scenarios: the user is slow to respond after requesting the challenge, or the application attempts to verify a challenge that was created some time ago and has since expired. Unlike `mfa_verification_failed` which indicates an incorrect code, this error means the entire challenge is no longer valid.
The primary fix is to create a new challenge immediately before verifying. Do not reuse challenge IDs:
// CORRECT: Create challenge, then verify
async function verifyMfaCode(factorId, userCode) {
try {
// Step 1: Create a fresh challenge
const { data: challengeData, error: challengeError } =
await supabase.auth.mfa.challenge({ factorId });
if (challengeError) {
console.error('Challenge creation failed:', challengeError);
return { success: false, error: 'Failed to create MFA challenge' };
}
// Step 2: Immediately verify with the fresh challenge
const { data, error } = await supabase.auth.mfa.verify({
factorId: factorId,
challengeId: challengeData.id, // Use the newly created challenge
code: userCode
});
if (error) {
console.error('MFA verification failed:', error.message);
return { success: false, error: error.message };
}
return { success: true, data: data };
} catch (err) {
console.error('MFA error:', err);
return { success: false, error: 'An unexpected error occurred' };
}
}Key point: Always call mfa.challenge() right before mfa.verify(), not minutes earlier.
Challenges are single-use and time-limited. Do not store challenge IDs in session storage, local storage, or client state for later use:
INCORRECT - Do NOT do this:
// BAD: Storing challenge ID and using it later
let cachedChallengeId = null;
async function requestMfaChallenge(factorId) {
const { data } = await supabase.auth.mfa.challenge({ factorId });
cachedChallengeId = data.id; // DON'T cache this
return cachedChallengeId;
}
// ... later, user submits code ...
async function verifyStoredChallenge(factorId, code) {
// This will fail if more than 5-10 minutes have passed
const { data, error } = await supabase.auth.mfa.verify({
factorId,
challengeId: cachedChallengeId, // EXPIRED!
code
});
}CORRECT - Create fresh challenge each time:
// GOOD: Create challenge and verify immediately
async function verifyMfaCode(factorId, code) {
const { data: challengeData } = await supabase.auth.mfa.challenge({ factorId });
const { data, error } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id, // Fresh challenge
code
});
return { data, error };
}Implement UI timers to remind users to complete MFA entry within the challenge window:
import { useState, useEffect } from 'react';
function MfaChallengeForm({ factorId, onSuccess }) {
const [code, setCode] = useState('');
const [timeRemaining, setTimeRemaining] = useState(600); // 10 minutes in seconds
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const interval = setInterval(() => {
setTimeRemaining(prev => {
if (prev <= 1) {
setError('Challenge expired. Please try again.');
clearInterval(interval);
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(interval);
}, []);
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (timeRemaining <= 0) {
setError('Challenge has expired. Please try again.');
return;
}
setLoading(true);
setError(null);
try {
// Create fresh challenge
const { data: challengeData, error: challengeError } =
await supabase.auth.mfa.challenge({ factorId });
if (challengeError) throw challengeError;
// Verify immediately
const { data, error } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code
});
if (error) {
setError(error.message);
setCode('');
} else {
onSuccess(data);
}
} catch (err) {
setError('Failed to verify MFA code');
console.error(err);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={handleSubmit}>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Enter 6-digit code"
maxLength={6}
disabled={loading || timeRemaining <= 0}
/>
<div style={{ fontSize: '0.9em', color: timeRemaining < 120 ? 'red' : 'gray' }}>
Time remaining: {formatTime(timeRemaining)}
</div>
{error && <div style={{ color: 'red' }}>{error}</div>}
<button type="submit" disabled={loading || timeRemaining <= 0}>
{loading ? 'Verifying...' : 'Verify'}
</button>
</form>
);
}For complex authentication flows with multiple steps, create a new challenge if the previous one is getting close to expiration:
class MfaManager {
constructor(factorId) {
this.factorId = factorId;
this.challengeId = null;
this.challengeCreatedAt = null;
this.CHALLENGE_LIFETIME = 600000; // 10 minutes in milliseconds
this.RENEWAL_THRESHOLD = 120000; // Renew if less than 2 minutes remain
}
async getValidChallenge() {
const now = Date.now();
// If no challenge exists, create one
if (!this.challengeId) {
return this.createChallenge();
}
// If challenge is getting close to expiration, renew it
const ageMs = now - this.challengeCreatedAt;
if (ageMs > (this.CHALLENGE_LIFETIME - this.RENEWAL_THRESHOLD)) {
console.log('Challenge approaching expiration, creating new one');
return this.createChallenge();
}
// Challenge is still valid
return this.challengeId;
}
async createChallenge() {
try {
const { data, error } = await supabase.auth.mfa.challenge({
factorId: this.factorId
});
if (error) throw error;
this.challengeId = data.id;
this.challengeCreatedAt = Date.now();
return this.challengeId;
} catch (err) {
console.error('Failed to create challenge:', err);
throw err;
}
}
async verify(code) {
const challengeId = await this.getValidChallenge();
const { data, error } = await supabase.auth.mfa.verify({
factorId: this.factorId,
challengeId,
code
});
return { data, error };
}
reset() {
this.challengeId = null;
this.challengeCreatedAt = null;
}
}
// Usage
const mfaManager = new MfaManager(factorId);
// Long flow with multiple steps
await mfaManager.getValidChallenge(); // Creates challenge
// ... user does other things ...
const { data, error } = await mfaManager.verify(userCode); // Automatically renews if neededImplement proper error handling to detect and recover from expired challenges:
async function verifyMfaWithRetry(factorId, code, maxRetries = 3) {
let attempts = 0;
while (attempts < maxRetries) {
try {
// Create fresh challenge
const { data: challengeData, error: challengeError } =
await supabase.auth.mfa.challenge({ factorId });
if (challengeError) {
throw new Error(`Challenge creation failed: ${challengeError.message}`);
}
// Verify
const { data, error } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code
});
if (error) {
// Check if error is due to expired challenge
if (error.code === 'mfa_challenge_expired') {
attempts++;
if (attempts < maxRetries) {
console.log(`Challenge expired, retrying (attempt ${attempts}/${maxRetries})...`);
// Retry with new challenge
continue;
} else {
throw new Error('MFA challenge expired. Please request a new code.');
}
} else {
// Other verification errors (e.g., invalid code)
throw new Error(`Verification failed: ${error.message}`);
}
}
return { success: true, data };
} catch (err) {
if (attempts >= maxRetries) {
return {
success: false,
error: err.message,
expired: err.message.includes('expired')
};
}
attempts++;
}
}
}
// Usage
const result = await verifyMfaWithRetry(factorId, userCode);
if (!result.success) {
if (result.expired) {
// Show user message to request a new challenge
showMessage('Your MFA challenge expired. Please request a new code.');
} else {
showMessage(result.error);
}
}For production applications, track how long authentication flows take and identify users experiencing timeouts:
// Server-side logging and monitoring
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(url, key);
interface MfaAttempt {
userId: string;
challengeCreatedAt: Date;
verificationAttemptedAt: Date;
durationMs: number;
success: boolean;
errorCode: string | null;
}
async function recordMfaAttempt(attempt: MfaAttempt) {
// Log to database for analysis
await supabase
.from('mfa_attempts')
.insert([{
user_id: attempt.userId,
challenge_created_at: attempt.challengeCreatedAt,
verification_attempted_at: attempt.verificationAttemptedAt,
duration_ms: attempt.durationMs,
success: attempt.success,
error_code: attempt.errorCode,
created_at: new Date()
}]);
// Alert if challenge was expired
if (attempt.errorCode === 'mfa_challenge_expired') {
console.warn(`User ${attempt.userId} experienced MFA challenge expiration after ${attempt.durationMs}ms`);
// Could trigger analytics or alerts
if (attempt.durationMs < 60000) {
// Expired too quickly - might indicate a bug
console.error('Challenge expired unexpectedly quickly');
}
}
}
// Usage in MFA flow
const startTime = Date.now();
const { data: challengeData } = await supabase.auth.mfa.challenge({ factorId });
// ... user enters code ...
const verificationStartTime = Date.now();
const { data, error } = await supabase.auth.mfa.verify({
factorId,
challengeId: challengeData.id,
code
});
const durationMs = verificationStartTime - startTime;
await recordMfaAttempt({
userId: user.id,
challengeCreatedAt: new Date(startTime),
verificationAttemptedAt: new Date(verificationStartTime),
durationMs,
success: !error,
errorCode: error?.code || null
});## Deep Dive: MFA Challenge Lifecycle in Supabase
### Challenge Validity Window
Each challenge created via supabase.auth.mfa.challenge() has a limited validity window:
- Typical TTL: 5-10 minutes (600-900 seconds)
- Phone MFA: 10 minutes for SMS/WhatsApp codes
- TOTP MFA: 10 minutes for challenge creation
The exact timeout is not documented but has been observed to be around 600 seconds (10 minutes) in practice.
### Challenge State
A challenge goes through the following states:
1. Created: Challenge ID is issued and valid
2. Active: Challenge can be used for verification
3. Expired: Challenge TTL has passed, verification will fail
4. Verified: Successfully verified (challenge is consumed)
5. Failed: Multiple failed verification attempts may trigger lockout
### Why Challenges Expire
Challenges expire for security reasons:
1. Replay Attack Prevention: Prevents an attacker from capturing a valid challenge ID and reusing it hours or days later
2. Code Uniqueness: Forces generation of new TOTP codes on each authentication attempt
3. Session Freshness: Ensures users complete authentication within a reasonable time frame
4. Rate Limiting: Combined with attempt limits, reduces brute force attack surface
### Common Scenarios Leading to Expiration
Scenario 1: Slow User Response
10:00:00 - Challenge created
10:05:30 - User enters code (challenge expired after 10 minutes)
Result: mfa_challenge_expiredScenario 2: Challenge Reuse
10:00:00 - Challenge A created
10:00:30 - Challenge A verified successfully
10:15:00 - Application tries to verify Challenge A again
Result: mfa_challenge_expired (already verified)Scenario 3: Network Delay
10:00:00 - Challenge created
10:10:00 - User submits code, but network is slow
10:10:45 - Verification request finally reaches server (after challenge expired)
Result: mfa_challenge_expired### Difference Between Challenge Types
For TOTP (Authenticator App):
- User has time to look at the app and read the code
- Code itself expires every 30 seconds (separate from challenge)
- Challenge window is 5-10 minutes
- Recommended: Use challengeAndVerify() API for atomic operation
For Phone (SMS/WhatsApp):
- Code is sent to user's phone and takes time to arrive
- Code is valid for up to 5 minutes after being sent
- Challenge window is the same as code validity
- Steps are separated: challenge creation → code delivery → user entry → verification
### Best Practice: Combined Challenge and Verify
For TOTP, Supabase provides a challengeAndVerify() method that combines both steps atomically:
// Single atomic operation - less chance of expiration
const { data, error } = await supabase.auth.mfa.challengeAndVerify({
factorId: factorId,
code: userCode // Code from authenticator app
});
if (error && error.code === 'mfa_challenge_expired') {
// This is very rare with challengeAndVerify since it's atomic
console.error('Challenge and verify failed');
}However, challengeAndVerify() is only suitable for TOTP. For Phone MFA, the steps must be separated.
### Handling Expired Challenges in Production
1. User-facing message: "Your authentication code has expired. Please request a new code."
2. Automatic retry: Don't retry automatically - user needs to interact (request new SMS/code)
3. Logging: Always log challenge expiration errors with timing information
4. UX Improvement: Show countdown timer to prevent user inactivity
5. Backend timeout: Set appropriate timeouts on backend operations to fail fast
### Debugging Challenge Expiration
Enable verbose logging to diagnose timing issues:
import { createClient } from '@supabase/supabase-js';
const supabase = createClient(url, key, {
auth: {
persistSession: true,
autoRefreshToken: true,
detectSessionInUrl: true,
debug: true // Enable debug logging
}
});
// Monitor auth state changes
supabase.auth.onAuthStateChange((event, session) => {
console.log('Auth event:', event, 'Session:', session?.user.id);
});
// Listen for MFA-specific events
supabase.auth.onAuthStateChange((event, session) => {
if (event === 'MFA_CHALLENGE_VERIFIED') {
console.log('MFA challenge verified at:', new Date().toISOString());
}
});### Recovery Strategies
If a user experiences MFA challenge expiration, provide:
1. Instant recovery: "Click here to request a new code"
2. Account recovery: Fallback to backup codes if available
3. Alternative factors: Let user use another MFA method (e.g., backup phone number)
4. Support contact: Clear path to support team for account lockout
email_conflict_identity_not_deletable: Cannot delete identity because of email conflict
How to fix "Cannot delete identity because of email conflict" in Supabase
conflict: Database conflict, usually related to concurrent requests
How to fix "database conflict usually related to concurrent requests" in Supabase
phone_exists: Phone number already exists
How to fix "phone_exists" in Supabase
StorageApiError: resource_already_exists
StorageApiError: Resource already exists
email_address_not_authorized: Email sending to this address is not authorized
Email address not authorized for sending in Supabase Auth