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

Source: https://supahost.app/kb/invalid-totp-code · Updated 2026-11-11

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

   ```ts
   const code = input.replace(/\s/g, "");
   ```

3. **Verify against the user's verified TOTP factor:**

   ```ts
   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:

   ```ts
   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:

   ```ts
   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.

## Related

- [Multi-factor authentication (TOTP)](/docs/auth/mfa)
- [Managing users (admin API)](/docs/auth/user-management)
- [Admin tasks on the server with the service_role key](/kb/server-side-admin-tasks)
- [Sessions, JWTs and refresh tokens](/docs/auth/sessions)
