# Two-factor setup

> A card that enrolls an authenticator app with a QR code, verifies the first code and removes the factor.

- Block: [mfa](https://bettersupabase.com/blocks/mfa.md)
- Kind: component
- Data: loaded on the server through the block's client
- Page: https://bettersupabase.com/recipes/mfa-setup

## Install the block

```bash
npx better-supabase add mfa
```

## Web (Next.js)

Add the shadcn components:

```bash
npx shadcn add badge button card field input-otp
```

From `apps/examples/nextjs/src/features/user/components/two-factor-card.tsx` (https://github.com/ScaleDockHQ/better-supabase/blob/main/apps/examples/nextjs/src/features/user/components/two-factor-card.tsx):

```tsx
"use client";

import { useSessionChange } from "better-supabase/next/client";
import { ShieldCheckIcon } from "lucide-react";
import { useExtracted } from "next-intl";
import Image from "next/image";
import { useId, useState, useTransition } from "react";
import { toast } from "sonner";

import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from "@/components/ui/field";
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp";
import { useSupabase } from "@/lib/hooks";

import type { AuthRead } from "../security-queries";

import { sessionChanged } from "../user-actions";

interface Enrollment {
  readonly factorId: string;
  readonly qrCode: string;
  readonly secret: string;
}

/**
 * TOTP through Supabase Auth MFA. The server reads the verified factor
 * (`getTotpFactorId`), so it is in the prefetched page; enrolling and
 * verifying run in the browser. A verified factor raises the session to
 * `aal2`, which `deleteMyAccount` and `DELETE /api/customers/:id` require.
 */
export function TwoFactorCard({
  initial,
}: {
  readonly initial: AuthRead<string | null>;
}) {
  const t = useExtracted("security");
  const fieldId = useId();
  const supabase = useSupabase();
  const changeSession = useSessionChange(sessionChanged);

  const refreshAndChange = useSessionChange(sessionChanged, undefined, {
    refreshToken: supabase,
  });

  const [factorId, setFactorId] = useState(initial.ok ? initial.value : null);
  const readError = initial.ok ? null : initial.error;
  const [enrollment, setEnrollment] = useState<Enrollment | null>(null);
  const [code, setCode] = useState("");
  const [error, setError] = useState<string | null>(null);
  const [pending, startTransition] = useTransition();

  const enroll = () => {
    startTransition(async () => {
      setError(null);

      const { data, error: enrollError } = await supabase.auth.mfa.enroll({
        factorType: "totp",
        friendlyName: `Acme Cloud ${new Date().toISOString().slice(0, 10)}`,
      });

      if (enrollError) {
        setError(enrollError.message);

        return;
      }

      setEnrollment({
        factorId: data.id,
        qrCode: data.totp.qr_code,
        secret: data.totp.secret,
      });
    });
  };

  const verify = () => {
    if (!enrollment) {
      return;
    }

    startTransition(async () => {
      const { error: verifyError } = await supabase.auth.mfa.challengeAndVerify(
        {
          factorId: enrollment.factorId,
          code,
        },
      );

      if (verifyError) {
        setError(t("That code didn't work. Try the next one."));

        return;
      }

      await changeSession();
      setFactorId(enrollment.factorId);
      setEnrollment(null);
      setCode("");
      toast.success(t("Two-factor authentication is on"));
    });
  };

  const remove = () => {
    if (!factorId) {
      return;
    }

    startTransition(async () => {
      const { error: removeError } = await supabase.auth.mfa.unenroll({
        factorId,
      });

      if (removeError) {
        toast.error(removeError.message);

        return;
      }

      await refreshAndChange();
      setFactorId(null);
      toast.success(t("Two-factor authentication is off"));
    });
  };

  return (
    <Card>
      <CardHeader>
        <CardTitle>{t("Two-factor authentication")}</CardTitle>
        <CardDescription>
          {t(
            "An authenticator app code at sign-in. Needed to delete your account.",
          )}
        </CardDescription>
        <CardAction>
          {readError ? null : factorId ? (
            <Badge data-testid="mfa-status">
              <ShieldCheckIcon />
              {t("On")}
            </Badge>
          ) : (
            <Badge variant="secondary" data-testid="mfa-status">
              {t("Off")}
            </Badge>
          )}
        </CardAction>
      </CardHeader>
      <CardContent className="space-y-4">
        {readError ? (
          <p className="text-sm text-destructive">{readError}</p>
        ) : enrollment ? (
          <div className="flex flex-col gap-6 sm:flex-row sm:items-start">
            {/* An SVG data URL from Supabase Auth: nothing to optimize. */}
            <Image
              src={enrollment.qrCode}
              width={160}
              height={160}
              unoptimized
              alt={t("QR code for your authenticator app")}
              className="rounded-lg bg-white p-2"
            />
            <div className="space-y-4">
              <Field data-invalid={error ? true : undefined}>
                <FieldLabel htmlFor={`${fieldId}-totp-code`}>
                  {t("Code from the app")}
                </FieldLabel>
                <InputOTP
                  id={`${fieldId}-totp-code`}
                  maxLength={6}
                  value={code}
                  onChange={setCode}
                >
                  <InputOTPGroup>
                    {Array.from({ length: 6 }, (_, index) => (
                      <InputOTPSlot key={index} index={index} />
                    ))}
                  </InputOTPGroup>
                </InputOTP>
                <FieldDescription>
                  {t("Or enter this key by hand: {secret}", {
                    secret: enrollment.secret,
                  })}
                </FieldDescription>
                {error ? <FieldError>{error}</FieldError> : null}
              </Field>
              <div className="flex gap-2">
                <Button
                  onClick={verify}
                  disabled={pending || code.length !== 6}
                >
                  {t("Verify")}
                </Button>
                <Button
                  variant="outline"
                  onClick={() => {
                    setEnrollment(null);
                  }}
                >
                  {t("Cancel")}
                </Button>
              </div>
            </div>
          </div>
        ) : factorId ? (
          <Button variant="outline" onClick={remove} disabled={pending}>
            {t("Turn off")}
          </Button>
        ) : (
          <>
            <Button onClick={enroll} disabled={pending}>
              {t("Set up")}
            </Button>
            {error ? <p className="text-sm text-destructive">{error}</p> : null}
          </>
        )}
      </CardContent>
    </Card>
  );
}
```

## Prompt

Paste this into a coding agent to build the recipe in your app.

### Web

```text
Build "Two-factor setup" in a React web app that uses shadcn/ui components. A card that enrolls an authenticator app with a QR code, verifies the first code and removes the factor.

It uses the better-supabase `mfa` block (MFA enforcement). It loads its data on the server through the block's client.

Steps:

1. If the app doesn't have the block yet, add the SQL with `npx better-supabase add mfa` and apply the migration.
2. Add the shadcn components the recipe uses: `npx shadcn add badge button card field input-otp`.
3. The recipe needs no glue: it calls the block's client on the server.
4. Write the component. Load the data on the server with the block's client, starting from the recipe's snippet (`apps/examples/nextjs/src/features/user/components/two-factor-card.tsx`). Adapt it to the app's existing components, styling and data loading instead of copying it as is.
5. Check the result against the recipe and the block's docs: it typechecks, it reads the same block data, and it handles the loading and empty states the snippet handles.

Read the full code first:

- Recipe: https://bettersupabase.com/recipes/mfa-setup.md
- Block docs: https://bettersupabase.com/docs/blocks/sql
```
