Skip to main content
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.
Login MFA adds a TOTP second factor at sign-in: after a user authenticates with their first factor (email, phone, OAuth, your 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.
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.

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 phaseMeaningWhat your UI does
awaiting_2fa_enrollmentFirst time — the user has no factor yetCall enrollMfa to mint a secret, render the QR, show backup codes, then collect the first code
awaiting_2faReturning — the user already has a factorPrompt 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.
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).
  • 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:
ModeBehavior
optionalOnly users who have enrolled a factor are challenged. New users aren’t forced to set one up.
requiredEvery user must set up a factor and pass it on each login.
disabledOff (the default).
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.

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.
Use useEnrollMfa and useVerifyMfa (from @getpara/react-sdk) together with the auth-state subscription. This mirrors the Custom OIDC example — 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:
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:
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>
  );
}
Each hook returns the standard React Query mutation fields, with mutate/mutateAsync renamed: useEnrollMfa(){ enrollMfa, enrollMfaAsync, isPending, ... }, useVerifyMfa(){ verifyMfa, verifyMfaAsync, isPending, ... }.

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

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.
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.
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.
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.

Next steps

Build a Custom UI

The full custom-login flow the MFA challenge plugs into.

Custom OIDC

Add your own identity provider as a first factor.