← All posts
·7 min read·GuardLayer

Supabase MFA blocks password reset (AAL2 required)

SupabaseAuthMFAPasswordsSecurity

401: AAL2 session is required to update email or password when MFA is enabled. means the session from your password-recovery link is AAL1, but changing a password on an MFA-enabled account requires AAL2. The fix is to run the MFA challenge after the recovery link creates the session and before you call updateUser({ password }).

You'll hit this the first time a user with a TOTP factor forgets their password. resetPasswordForEmail works. The email arrives. The link works. A session exists. And then updateUser returns a 401 that reads like a permissions bug in your own code.

supabase/auth issue #2091 — "resetPasswordForEmail + MFA: Cannot update password due to AAL2 requirement". Still open. The reporter has a valid session from the recovery link, gets the 401 on updateUser({ password }), and is asking the maintainers whether the behaviour is even intended, because the docs don't say.

It is intended, and once you see why, the fix is straightforward.

AAL1 vs AAL2, precisely

Supabase tags every session with an authenticator assurance level, exposed as the aal claim in the JWT. The docs define the two levels:

Assurance Level 1 (aal1): "the user's identity was verified using a conventional login method such as email+password, magic link, one-time password, phone auth or social login."

Assurance Level 2 (aal2): "the user's identity was additionally verified using at least one second factor, such as a TOTP code or One-Time Password code."

A password-recovery link is, by that definition, a conventional login method. It proves control of the inbox and nothing more. So the session it mints is aal1 — and Supabase refuses to let an aal1 session change the password on an account that has a verified second factor.

That's not an oversight. If a recovery link alone could rewrite the password, then anyone who compromises the email account owns the Supabase account outright, and the second factor the user deliberately enrolled protects nothing. The whole point of MFA is that email possession stops being sufficient. The 401 is the guarantee working.

How do I let MFA users reset their password in Supabase?

Elevate the recovery session to AAL2 with the second factor, then update the password. Three steps on your /reset-password page:

"use client";
import { createClient } from "@/lib/supabase/client";

const supabase = createClient();

export async function completeReset(newPassword: string, totpCode?: string) {
  // 1. Does this session need to step up?
  const { data: aal } =
    await supabase.auth.mfa.getAuthenticatorAssuranceLevel();

  if (aal?.nextLevel === "aal2" && aal.nextLevel !== aal.currentLevel) {
    if (!totpCode) {
      // Render the "enter your 6-digit code" field and come back.
      return { needsMfa: true };
    }

    // 2. Verify the second factor against this session.
    const { data: factors } = await supabase.auth.mfa.listFactors();
    const factorId = factors?.totp?.[0]?.id;
    if (!factorId) return { error: "No enrolled factor found." };

    const { error: mfaError } = await supabase.auth.mfa.challengeAndVerify({
      factorId,
      code: totpCode,
    });
    if (mfaError) return { error: mfaError.message };
    // The session is now aal2.
  }

  // 3. Now the password change is allowed.
  const { error } = await supabase.auth.updateUser({ password: newPassword });
  return error ? { error: error.message } : { ok: true };
}

The gate to branch on is getAuthenticatorAssuranceLevel(). It returns currentLevel (what this session has) and nextLevel (what it could reach). When nextLevel is aal2 and currentLevel isn't, the user has an enrolled factor and hasn't used it yet — that's exactly the state a recovery link leaves you in. Users without MFA get nextLevel === "aal1" and skip the whole branch, so one code path serves both.

challengeAndVerify is the convenience wrapper: it "creates a challenge and immediately uses the given code to verify against it", so you don't have to hold a challenge id between two calls.

The case you have to design for: genuine lockout

The flow above works when the user still has their authenticator. When they've lost the phone and forgotten the password, they are locked out by design, and no client-side call will help — that's the property MFA is supposed to have.

Your only recovery path runs server-side with the service role key, which can unenroll the factor on the user's behalf. Treat that endpoint as the single most sensitive route in your application: it deliberately removes a security control, so anyone who can reach it can strip MFA from any account.

// app/api/admin/mfa/reset/route.ts — server-only, service role key.
// Gate this behind real operator auth and log every invocation.
const { data: factors } =
  await adminClient.auth.admin.mfa.listFactors({ userId });

for (const factor of factors?.factors ?? []) {
  await adminClient.auth.admin.mfa.deleteFactor({ userId, id: factor.id });
}

Three non-negotiables around that code:

  • Never let the key reach the browser. Everything about why is in how the service role key leaks — it bypasses RLS entirely, so a leak here is a full database compromise on top of an MFA bypass.
  • Verify identity out of band first. Email possession is precisely what the user's second factor was protecting against. A support-ticket workflow with a human check is the minimum bar.
  • Expect AuthApiError: User not allowed if you get the key wrong. Calling auth.admin.* with an anon or publishable key produces that error, and it's the most common reason admin endpoints fail.

While you're here: enforce AAL2 in the database too

Blocking a password change is Supabase's own enforcement. Your tables get none of it for free — a policy that only checks auth.uid() is satisfied by an aal1 session. For genuinely sensitive tables, require the second factor in the policy itself, using the pattern from Supabase's MFA docs:

create policy "Requires MFA to read billing"
  on public.billing_accounts
  as restrictive
  to authenticated
  using ((select auth.jwt() ->> 'aal') = 'aal2');

as restrictive matters: restrictive policies are ANDed with your existing permissive ones, so this adds the MFA requirement on top of your normal access rules rather than opening a second way in. Applied to a table where every user has already enrolled a factor, it means a stolen aal1 session can't touch the data.

Quick self-check

// Which level is this session at right now?
const { data } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();
console.log(data); // { currentLevel: "aal1", nextLevel: "aal2" } -> step-up needed

And in SQL, from a client authenticated as the user:

select auth.jwt() ->> 'aal';

If your reset page never renders a code field, run the first snippet on it — you'll almost certainly see currentLevel: "aal1" with nextLevel: "aal2", which is the 401 waiting to happen.

FAQ

Is this a Supabase bug? No, though the documentation gap is real enough that the issue is still open. Allowing an emailed link to override a second factor would defeat MFA, so the restriction is deliberate.

Can I skip the challenge by calling updateUser with the service role key? Technically yes, via auth.admin.updateUserById. Don't wire it into a user-facing reset flow — you'd be rebuilding the exact bypass the 401 exists to prevent. Keep it for operator-initiated recovery with out-of-band identity checks.

Does this affect email changes as well? Yes. The error names both: "update email or password". Any email change from an MFA-enabled account needs an AAL2 session too.

How do I know whether a user has MFA before they hit the error? Call getAuthenticatorAssuranceLevel() as soon as the recovery session exists. If nextLevel is aal2, render the code field before the password field instead of after the failure.

What if the user never verified the factor they enrolled? Unverified factors don't count toward AAL2 and listFactors() will show their status. If the only factor is unverified, the account shouldn't be hitting this at all — check what's actually enrolled before building a recovery path around it.

Does any of this replace basic password hygiene? No. MFA raises the floor, but you still want leaked password protection turned on so the new password the user picks isn't one that's already in a breach corpus.

Catch this before it ships — free

GuardLayer scans every push for this and 28 other Next.js + Supabase issues, with the exact fix inline.

No signup, no card — your code is scanned in memory and never stored.

Keep reading

A Solvion project — see also Reglog — EU AI Act changelog, Proceedly, Solenna and Solvion Solutions.