> ## Documentation Index
> Fetch the complete documentation index at: https://docs.getpara.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Login Two-Factor Authentication (MFA)

> Require a TOTP second factor at sign-in. Integrate it with the Para React hooks or your own fully custom UI.

<Info>
  **Early access — hidden.** Login MFA isn't in the Para Modal yet, so there's no built-in UI for it. You integrate it with your **own** UI today (React hooks or fully custom). Reach out to your Para contact to enable it on your project.
</Info>

Login MFA adds a **TOTP second factor at sign-in**: after a user authenticates with their first factor (email, phone, OAuth, your [Custom OIDC](/v3/general/developer-portal-custom-oidc) provider, etc.), Para can require them to set up and present a time-based one-time code from an authenticator app before the login completes and their wallet unlocks.

<Note>
  This is **login-time MFA** — a second factor on every sign-in. It's distinct from Para's **recovery 2FA** (`useSetup2fa` / `useVerify2fa`), which gates account recovery. The hooks and methods below (`enrollMfa` / `verifyMfa`) are login MFA only.
</Note>

## How it works

When login MFA is owed, the SDK **pauses the login** on a challenge and surfaces it through the auth state. There are two challenge phases:

| Auth phase                | Meaning                                   | What your UI does                                                                                |
| ------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------ |
| `awaiting_2fa_enrollment` | First time — the user has no factor yet   | Call `enrollMfa` to mint a secret, render the QR, show backup codes, then collect the first code |
| `awaiting_2fa`            | Returning — the user already has a factor | Prompt for a code                                                                                |

Your UI watches for these phases, drives enrollment/verification, and submits the code. On a correct code the SDK **re-polls the login and advances on its own** toward the connected wallet — you don't need to restart the flow.

```text theme={null}
first factor ──▶ awaiting_2fa_enrollment ──enroll──▶ awaiting_2fa ──verify ok──▶ authenticated
                 (new user)                          (or returning)
```

## Before you start

* Para has **enabled login MFA** on your project (see [Enabling login MFA](#enabling-login-mfa)).
* The Para React SDK (`@getpara/react-sdk`) or Web SDK (`@getpara/web-sdk`), v3.
* A **custom login UI** — login MFA has no Para Modal UI yet, so you render the challenge yourself.

## Enabling login MFA

Login MFA is configured on your project's partner record by the **Para team** — it isn't self-serve while the feature is in early access. Reach out to your Para contact and tell them which mode you want:

| Mode       | Behavior                                                                                     |
| ---------- | -------------------------------------------------------------------------------------------- |
| `optional` | Only users who have enrolled a factor are challenged. New users aren't forced to set one up. |
| `required` | Every user must set up a factor and pass it on each login.                                   |
| `disabled` | Off (the default).                                                                           |

<Note>
  The CLI/Developer Portal **"Enable two-factor authentication"** toggle controls **recovery** 2FA, not login MFA — don't use it for this. Login MFA is enabled separately by Para.
</Note>

## Integrate it

Pick the path that matches how you build your login UI. Both drive the same `enrollMfa` / `verifyMfa` flow — the hooks just wrap it in React Query state.

<Tabs>
  <Tab title="React hooks">
    Use **`useEnrollMfa`** and **`useVerifyMfa`** (from `@getpara/react-sdk`) together with the auth-state subscription. This mirrors the [Custom OIDC example](https://github.com/getpara/examples-hub/tree/3/web/with-react-nextjs/custom-oidc-auth) — a small hook surfaces the challenge from the SDK state and drives it.

    ### A challenge hook

    Subscribe to the auth state to detect the hold, fetch the enrollment secret once, and expose a `verify` function:

    ```tsx theme={null}
    import { useCallback, useEffect, useRef, useState } from "react";
    import type { StateSnapshot } from "@getpara/web-sdk";
    import { useClient, useEnrollMfa, useVerifyMfa } from "@getpara/react-sdk";

    type MfaMode = "enroll" | "verify";

    export function useMfaChallenge() {
      const para = useClient();
      const { enrollMfaAsync } = useEnrollMfa();
      const { verifyMfaAsync } = useVerifyMfa();

      const [mode, setMode] = useState<MfaMode | null>(null);
      const [enrollment, setEnrollment] = useState<{ uri: string; backupCodes: string[] } | null>(null);
      const [attemptsRemaining, setAttemptsRemaining] = useState<number | null>(null);
      const [error, setError] = useState<string | null>(null);

      // enrollMfa() mints a fresh secret + backup codes each call, so enroll once per challenge.
      const hasEnrolled = useRef(false);

      useEffect(() => {
        if (!para) return;
        const unsubscribe = para.onStatePhaseChange(async (snapshot: StateSnapshot) => {
          if (snapshot.authPhase === "awaiting_2fa_enrollment") {
            setMode("enroll");
            if (hasEnrolled.current) return;
            hasEnrolled.current = true;
            try {
              const { uri, backupCodes } = await enrollMfaAsync(); // otpauth:// uri + one-time codes
              setEnrollment({ uri, backupCodes });
            } catch (e) {
              hasEnrolled.current = false; // allow a retry on the next state tick
              setError("Could not start two-factor setup.");
            }
          } else if (snapshot.authPhase === "awaiting_2fa") {
            setMode("verify");
          } else {
            // Left the hold (advanced, cancelled, or errored) — reset so stale codes never linger.
            setMode(null);
            setEnrollment(null);
            setAttemptsRemaining(null);
            setError(null);
            hasEnrolled.current = false;
          }
        });
        return () => unsubscribe();
      }, [para, enrollMfaAsync]);

      const verify = useCallback(
        async (code: string): Promise<boolean> => {
          setError(null);
          // On { ok: true } the SDK has already re-polled the login — the auth state advances
          // toward the wallet on its own. On { ok: false } stay on the prompt.
          const result = await verifyMfaAsync({ code });
          if (result.ok) return true;
          setAttemptsRemaining(result.attemptsRemaining ?? null);
          setError(`Incorrect code. ${result.attemptsRemaining ?? 0} attempt(s) remaining.`);
          return false;
        },
        [verifyMfaAsync],
      );

      return { mode, enrollment, attemptsRemaining, error, verify };
    }
    ```

    ### Render the challenge

    Render a QR from the `uri` (any QR component works), show the backup codes once during enrollment, and collect a 6-digit code:

    ```tsx theme={null}
    import { useState } from "react";
    import { QRCodeSVG } from "qrcode.react";
    import { useMfaChallenge } from "./useMfaChallenge";

    export function MfaChallenge() {
      const { mode, enrollment, attemptsRemaining, error, verify } = useMfaChallenge();
      const [code, setCode] = useState("");

      if (!mode) return null; // not parked on a 2FA challenge

      return (
        <div>
          {mode === "enroll" && enrollment && (
            <>
              <p>Scan this with your authenticator app:</p>
              <QRCodeSVG value={enrollment.uri} />
              <p>Save these backup codes — they're shown only once:</p>
              <ul>{enrollment.backupCodes.map((c) => <li key={c}>{c}</li>)}</ul>
            </>
          )}

          <p>{mode === "enroll" ? "Enter the code to finish setup" : "Enter your authentication code"}</p>
          <input value={code} onChange={(e) => setCode(e.target.value)} inputMode="numeric" />
          <button onClick={() => verify(code)}>Verify</button>

          {error && <p>{error}{attemptsRemaining === 0 && " Please sign in again."}</p>}
        </div>
      );
    }
    ```

    <Tip>
      Each hook returns the standard React Query mutation fields, with `mutate`/`mutateAsync` renamed: `useEnrollMfa()` → `{ enrollMfa, enrollMfaAsync, isPending, ... }`, `useVerifyMfa()` → `{ verifyMfa, verifyMfaAsync, isPending, ... }`.
    </Tip>
  </Tab>

  <Tab title="Fully custom UI (Web SDK)">
    If you don't use the React hooks, call the methods on your Para client directly. The shape is identical — `para.enrollMfa()` and `para.verifyMfa()` — and you detect the hold with the same `onStatePhaseChange` subscription.

    ```ts theme={null}
    import { para } from "./your-para-client";
    import type { StateSnapshot } from "@getpara/web-sdk";

    // Watch the login for a 2FA hold.
    para.onStatePhaseChange(async (snapshot: StateSnapshot) => {
      if (snapshot.authPhase === "awaiting_2fa_enrollment") {
        // New user — mint a secret and show setup UI.
        const { uri, backupCodes } = await para.enrollMfa();
        renderQrCode(uri);          // otpauth:// — render as a QR
        showBackupCodes(backupCodes); // one-time — show exactly once
        showCodeInput();
      } else if (snapshot.authPhase === "awaiting_2fa") {
        // Returning user — just prompt for a code.
        showCodeInput();
      }
    });

    // When the user submits a TOTP or backup code:
    async function submitMfaCode(code: string) {
      const result = await para.verifyMfa({ code });
      if (result.ok) {
        // SDK re-polls the login automatically — the wallet unlocks on its own.
        return;
      }
      // Wrong code — re-prompt. result.attemptsRemaining tells you how many tries are left.
      showError(result.attemptsRemaining);
    }
    ```

    <Note>
      `enrollMfa()` resolves to `{ uri, backupCodes }`; `verifyMfa({ code })` resolves to `{ ok: true }` or `{ ok: false, attemptsRemaining }`. Treat `{ ok: false }` as a re-prompt, not a hard error.
    </Note>
  </Tab>
</Tabs>

## Backup codes

`enrollMfa` returns a set of **one-time backup codes** alongside the QR `uri`. Show them to the user **once**, during setup, and tell them to store the codes somewhere safe — they're how a user gets in if they lose their authenticator. Para stores only hashes and never shows them again.

A backup code is accepted anywhere a TOTP code is — pass it to `verifyMfa({ code })` exactly the same way. Each backup code works once.

## Handling wrong codes and lockout

`verifyMfa` returns `{ ok: false, attemptsRemaining }` for an incorrect code. Surface `attemptsRemaining` and let the user try again — don't treat it as a fatal error.

After too many wrong codes the session is **locked out**; the user must restart the login (a fresh sign-in resets the budget). When `attemptsRemaining` reaches `0`, prompt the user to sign in again.

## Security

* The TOTP secret is generated server-side and stored **encrypted** — your UI only ever receives the `otpauth://` provisioning `uri` to render.
* Backup codes are **single-use** and stored as hashes; they're returned in plaintext only once, at enrollment.
* A correct factor is what releases the user's wallet keyshares for the session — the gate is enforced on Para's backend, not in your UI, so it can't be bypassed client-side.

## Troubleshooting

<AccordionGroup>
  <Accordion title="The login never shows a 2FA challenge">
    Confirm Para has enabled login MFA on your project (`optional` or `required`). In `optional` mode, only users who have already enrolled a factor are challenged — a brand-new user won't be unless the mode is `required`. Also make sure your UI subscribes to `onStatePhaseChange` and renders on `awaiting_2fa` / `awaiting_2fa_enrollment`.
  </Accordion>

  <Accordion title="enrollMfa returns a new QR every time / duplicate codes">
    Each `enrollMfa()` call mints a fresh secret and a new set of backup codes. Call it **once** per enrollment challenge (guard it, as the example does with a ref) so the QR and backup codes are stable while the user scans them.
  </Accordion>

  <Accordion title="A correct code doesn't advance the login">
    On `{ ok: true }` the SDK re-polls the login itself — don't also re-trigger authentication. If you're keeping the auth popup/window open, make sure it stays open until the wallet connects.
  </Accordion>

  <Accordion title="Codes are always rejected">
    TOTP codes are time-based — make sure the user's device clock is accurate. Para accepts a small skew window. If they've lost their authenticator, have them use a backup code instead.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Build a Custom UI" icon="palette" href="/v3/react/guides/custom-ui-simplified">
    The full custom-login flow the MFA challenge plugs into.
  </Card>

  <Card title="Custom OIDC" icon="key" href="/v3/general/developer-portal-custom-oidc">
    Add your own identity provider as a first factor.
  </Card>
</CardGroup>
