Supabase MFA with an authenticator app

Supabase Auth's TOTP factor behind an animated code screen: enrol with the QR Supabase gives you, then challenge and verify the six digits to lift the session to aal2.

Step-Up OTP Gate, previewLoading live demo
One payment$3

Not downloaded yet? Full refund within 14 days. Broken files get fixed or refunded. Refunds

Just this one, or all 16?

Step-Up OTP Gate$3

Just this one component.

The Shelf (All-Access)$29$54 if bought one by one

All 16 components and templates in the catalogue today, this one included. Pay once, keep them.

By buying you agree to the Terms and ask for instant access, so you lose the 14-day withdrawal right. When we still refundWe'll also email you when a new piece lands (about once a week). Unsubscribe in one click, anytime.

  • Download right after paying
  • Next.js + Cursor/v0 ready
  • Use it in client projects

By buying you agree to the Terms and ask for instant access, so you lose the 14-day withdrawal right. When we still refundWe'll also email you when a new piece lands (about once a week). Unsubscribe in one click, anytime.

Paid through Stripe. We never see your card.

Ready forCursorv0Lovableshadcn CLI

Full product pageReact · Tailwind · TypeScript

Use it in Next.js

app/(app)/MfaGate.tsx

"use client";
import { useRouter } from "next/navigation";
import { StepUpGate } from "@/components/StepUpGate";
import { createClient } from "@/lib/supabase/client";

const supabase = createClient();

export function MfaGate({ factorId, children }: { factorId: string; children: React.ReactNode }) {
  const router = useRouter();
  return (
    <StepUpGate
      title="Two-factor check"
      description="Enter the 6-digit code from your authenticator app."
      onVerify={async (code) => {
        const { error } = await supabase.auth.mfa.challengeAndVerify({ factorId, code });
        return !error; // the session is now aal2
      }}
      onVerified={() => router.refresh()} // server components re-read the upgraded session
    >
      {children}
    </StepUpGate>
  );
}

app/(app)/require-aal2.tsx

import { createClient } from "@/lib/supabase/server";
import { MfaGate } from "./MfaGate";

// The code screen while this session still needs its second factor, or null once it's done.
export async function requireAal2() {
  const supabase = await createClient();
  const { data: aal } = await supabase.auth.mfa.getAuthenticatorAssuranceLevel();

  // enrolled but not verified in this session: ask for the code
  if (aal?.currentLevel === "aal1" && aal.nextLevel === "aal2") {
    const { data } = await supabase.auth.mfa.listFactors();
    const factorId = data?.totp[0]?.id;
    if (factorId) return <MfaGate factorId={factorId}>{null}</MfaGate>;
  }
  return null;
}

app/(app)/billing/page.tsx (any private page)

import { requireAal2 } from "../require-aal2";

export default async function BillingPage() {
  const gate = await requireAal2();
  if (gate) return gate; // stop here: nothing below runs or reaches the browser at aal1

  // read private data only after the check
  return <main>Your balance and invoices</main>;
}

Enrolment (settings page, client)

const { data, error } = await supabase.auth.mfa.enroll({ factorType: "totp" });
// data.totp.qr_code is an SVG image: <img src={data.totp.qr_code} />
// then confirm with the first code, which also activates the factor:
await supabase.auth.mfa.challengeAndVerify({ factorId: data.id, code });

Good to know

  • The check sits at the top of each private page, not in a layout. Next.js still sends a page to the browser when its layout leaves {children} out, so a layout gate would leak the page at aal1. We caught that in our own test. After router.refresh() the server sees aal2 and renders the page.
  • lib/supabase/client.ts and server.ts are the usual helpers from Supabase's Next.js guide (createBrowserClient and createServerClient from @supabase/ssr).
  • Turn on TOTP under Authentication > Multi-Factor in the Supabase dashboard. It's free on every plan.
  • Protect the data itself too: add a row level security policy that checks (auth.jwt() ->> 'aal') = 'aal2' on tables that need the second factor.
  • A wrong code makes challengeAndVerify return an error, the gate shakes, and the visitor can type again.
  • No onResend is passed, so the gate hides its Resend link. An authenticator app has nothing to resend. Not now shows a short neutral line; set cancelMessage to use your own words. Bought before October 22, 2026? Download the zip again to get both.
  • Tested on Oct 21, 2026 in a fresh Next.js 15.5 app (React 19.1, @supabase/ssr 0.7, supabase-js 2.118) against a local mock of the Supabase Auth API, at 1440 and 390 px wide. At aal1 the page HTML holds only the code screen. A wrong code shakes the gate, the right one moves the session cookie to aal2 and the page appears. Enrolment returned a QR that renders. Not yet run on a live Supabase project.

Install

  1. Copy from the zipPut react/src/components + react/src/data into src/, then npm i framer-motion @supabase/supabase-js @supabase/ssr.
  2. Or ask your AIPaste PROMPT.md into Cursor, Lovable or v0.

Props

onVerify
(code) → Promise<boolean>, true unlocks
onVerified
after the unlock animation
title / description
the dialog's text
length
number of cells, 6 by default
autoSubmit
submit when the last cell fills

How qodesign compares

All comparisons, with prices read on each library's own site.

Questions

Can I use it for Supabase phone (SMS) MFA too?

Yes. Use factorType "phone", call challenge to send the SMS, and wire onResend to a new challenge.

Is the check done on the server?

Supabase does it. challengeAndVerify sends the code to Supabase Auth, which upgrades the session cookie, so a faked true in the browser gets nobody past an aal2 policy.

Can Cursor or v0 add it for me?

Yes. Each download has a PROMPT.md. Paste it into Cursor, Lovable, v0 or Claude Code and it fits the piece into your app.

Can I use it in client work?

Yes, in personal and client projects. You can't resell the code itself as a kit.