Invalid TOTP code entered

MFA verification fails with "Invalid TOTP code entered". Causes include clock drift, a stale authenticator entry or the wrong factor id, plus how an admin can reset a user's factor.

What you see

Enrolling or verifying a TOTP factor fails, from supabase.auth.mfa.verify() or challengeAndVerify():

AuthApiError: Invalid TOTP code entered

The response is 422 with error_code: "mfa_verification_failed". The user insists the code is right.

Why it happens

A TOTP code is computed from a shared secret and the current time, and changes every 30 seconds. Auth compares it with the code it expects at its own clock time. It fails when:

  • The device clock is off. A phone or laptop set by hand can drift by more than the small window Auth allows.
  • The code is stale or mistyped. It expired while being typed, or contains a space copied from the app.
  • The authenticator entry is old. The user enrolled twice, and the app shows a code for an earlier, abandoned factor. Staging and production entries with the same name are a classic mix-up.
  • The factor id is wrong. Your code challenged one factor while the user typed the code for another.

An expired challenge fails differently, with mfa_challenge_expired. Request a new challenge in that case.

How to fix it

  1. Ask the user to turn on automatic date and time on their device, then try a fresh code.

  2. Clean the input:

    const code = input.replace(/\s/g, "");
    
  3. Verify against the user's verified TOTP factor:

    const { data: factors, error } = await supabase.auth.mfa.listFactors();
    if (error) throw error;
    
    const totp = factors.totp[0]; // verified TOTP factors only
    const { error: verifyError } = await supabase.auth.mfa.challengeAndVerify({
      factorId: totp.id,
      code,
    });
    
  4. Remove abandoned enrolments before enrolling again, and give each environment a clear name so the authenticator app shows which is which:

    const { data } = await supabase.auth.mfa.listFactors();
    for (const f of data?.all.filter((f) => f.status === "unverified") ?? []) {
      await supabase.auth.mfa.unenroll({ factorId: f.id });
    }
    
    await supabase.auth.mfa.enroll({
      factorType: "totp",
      friendlyName: "Invoices (staging)",
    });
    

    Tell users to delete the old entry from their authenticator app.

  5. Reset a factor for a user who lost their device. On your server, with the service_role key, after confirming who they are some other way:

    const { data } = await admin.auth.admin.mfa.listFactors({ userId });
    for (const f of data?.factors ?? []) {
      await admin.auth.admin.mfa.deleteFactor({ id: f.id, userId });
    }
    

    The user can then sign in with their password alone and enrol again.

On SupaHost

  • TOTP MFA works on every plan. There are no MFA settings on the Auth tab, and phone (SMS) MFA isn't offered.
  • Codes are checked by your project's Auth service on your instance, so the server's time is the reference, not the time on the user's device.
  • Only call the admin MFA API from server code or an Edge Function, where SUPABASE_SERVICE_ROLE_KEY is available.

Updated 11 November 2026 · View as Markdown · Something wrong or missing? Tell us