How to fix mfa_challenge_expired: MFA challenge has expired in Supabase

SupabaseINTERMEDIATEMEDIUMUpdated Dec 29, 2025· 187 views

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.

Quick fix

Fixes mfa_challenge_expired: MFA challenge has expired

// 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' };
  }
}
  1. Create a fresh MFA challenge before verification
  2. Do not reuse or cache challenge IDs across requests
  3. Set appropriate timeout limits in your UI
  4. Implement automatic challenge renewal for long flows
supabase — error
// 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' };
}
}
mfa_challenge_expired: MFA challenge has expired

What this error means

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.

Typical symptoms

  • Users receive "MFA challenge has expired" error when entering their MFA code
  • Challenge works initially but fails if user takes more than 5-10 minutes to enter their code
  • Verification fails with the same code that was valid moments earlier
  • Error appears consistently when users are slow to respond to MFA prompts
  • Application receives 403 or 401 response with mfa_challenge_expired error code
  • Challenge verification fails even before checking the actual code value

Common causes

  1. User took too long to retrieve and enter their MFA code (challenge lifetime exceeded)
  2. Verification request is using a challenge ID from a previous authentication attempt that has expired
  3. Application is retrying verification with the same challenge ID after more than 5-10 minutes
  4. Network delay caused the verification request to be submitted after the challenge window closed
  5. User paused during MFA entry and submitted their code after the challenge expired
  6. Challenge was created but verification was never attempted until after the timeout period
  7. Browser or client cached an expired challenge ID and attempted to reuse it

How to fix "mfa_challenge_expired: MFA challenge has expired"

1Create a fresh MFA challenge before verification

The primary fix is to create a new challenge immediately before verifying. Do not reuse challenge IDs:

javascript
// 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.

2Do not reuse or cache challenge IDs across requests

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:

javascript
// 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:

javascript
// 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 };
}
3Set appropriate timeout limits in your UI

Implement UI timers to remind users to complete MFA entry within the challenge window:

javascript
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>
  );
}
4Implement automatic challenge renewal for long flows

For complex authentication flows with multiple steps, create a new challenge if the previous one is getting close to expiration:

javascript
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 needed
5Handle challenge expiration errors gracefully

Implement proper error handling to detect and recover from expired challenges:

javascript
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);
  }
}
6Monitor MFA authentication flow timing

For production applications, track how long authentication flows take and identify users experiencing timeouts:

javascript
// 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
});

Advanced notes

## 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

bash
10:00:00 - Challenge created
10:05:30 - User enters code (challenge expired after 10 minutes)
Result: mfa_challenge_expired

Scenario 2: Challenge Reuse

bash
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

bash
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:

javascript
// 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:

typescript
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

Frequently asked questions

What does "mfa_challenge_expired: MFA challenge has expired" mean?
The "mfa_challenge_expired" error is a Supabase Auth error code that indicates an MFA verification attempt has been made with an expired challenge. 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.
How do I fix "mfa_challenge_expired: MFA challenge has expired"?
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.
What causes "mfa_challenge_expired: MFA challenge has expired"?
User took too long to retrieve and enter their MFA code (challenge lifetime exceeded) Verification request is using a challenge ID from a previous authentication attempt that has expired Application is retrying verification with the same challenge ID after more than 5-10 minutes
Is the "mfa_challenge_expired: MFA challenge has expired" error serious?
It is a medium-severity error — more of a blocker than a risk. The steps above resolve it.

Official resources & further reading