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
Ask the user to turn on automatic date and time on their device, then try a fresh code.
Clean the input:
const code = input.replace(/\s/g, "");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, });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.
Reset a factor for a user who lost their device. On your server, with the
service_rolekey, 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_KEYis available.