This guide covers building a complete TOTP MFA flow with a custom UI. This includes adding a device, showing a QR code, verifying the device, and handling backup codes.
Headless MFA supports only one verified TOTP device per user.

1. Component Setup & Hooks

First, set up your component state and import the necessary hooks. You’ll need to manage the user’s devices, the current UI view (e.g., device list, QR code, or OTP input), and errors.
import { FC, useEffect, useState, useRef } from "react";
import {
  useDynamicContext,
  useIsLoggedIn,
  useMfa,
  useSyncMfaFlow,
} from "@dynamic-labs/sdk-react-core";
import { MFADevice } from "@dynamic-labs/sdk-api-core";
import QRCodeUtil from "qrcode";

type MfaRegisterData = {
  uri: string;
  secret: string;
};

export const TOTPMfaView = () => {
  const [userDevices, setUserDevices] = useState<MFADevice[]>([]);
  const [mfaRegisterData, setMfaRegisterData] = useState<MfaRegisterData>();
  const [currentView, setCurrentView] = useState<string>("devices");
  const [backupCodes, setBackupCodes] = useState<string[]>([]);
  const [error, setError] = useState<string>();

  const isLogged = useIsLoggedIn();
  const {
    addDevice,
    authenticateDevice,
    getUserDevices,
    getRecoveryCodes,
    completeAcknowledgement,
  } = useMfa();
    const { userWithMissingInfo } = useDynamicContext();
    
    // ... implementation continues below

2. Fetching User Devices

On component mount, if the user is logged in, fetch their MFA devices to display them.
// ... inside TOTPMfaView component

  const refreshUserDevices = async () => {
    const devices = await getUserDevices();
    setUserDevices(devices);
  };

  useEffect(() => {
    if (isLogged) {
      refreshUserDevices();
    }
  }, [isLogged]);

  // ... implementation continues below

3. Handling the MFA Flow

The useSyncMfaFlow hook is key for account-based MFA. It detects when an MFA challenge is required and calls your handler, letting you update the UI to prompt the user.
// ... inside TOTPMfaView component

  useSyncMfaFlow({
    handler: async () => {
      // This scope indicates that the user needs to complete MFA to proceed
      if (userWithMissingInfo?.scope?.includes("requiresAdditionalAuth")) {
        const devices = await getUserDevices();
        if (devices.length === 0) {
          // If no devices, start the add device flow
          setError(undefined);
          const { uri, secret } = await addDevice();
          setMfaRegisterData({ secret, uri });
          setCurrentView("qr-code");
        } else {
          // If a device exists, prompt for the OTP code
          setError(undefined);
          setMfaRegisterData(undefined);
          setCurrentView("otp");
        }
      } else {
        // If MFA is complete, show backup codes
        const codes = await getRecoveryCodes();
        setBackupCodes(codes);
        setCurrentView("backup-codes");
      }
    },
  });

  // ... implementation continues below

4. Adding a New Device

Create a function to start the addDevice flow. This generates a secret and URI for displaying a QR code.
// ... inside TOTPMfaView component

  const onAddDevice = async () => {
    setError(undefined);
    const { uri, secret } = await addDevice();
    setMfaRegisterData({ secret, uri });
    setCurrentView("qr-code");
  };

  // ... implementation continues below

5. Authenticating the Device

After the user scans the QR code and enters an OTP from their authenticator app, use authenticateDevice to verify the code.

For Account-Based MFA

For account-based MFA, you don’t need to create a long-lived token. Authenticating the device completes the login flow.
// ... inside TOTPMfaView component

  const onOtpSubmit = async (code: string) => {
    try {
      await authenticateDevice({ code });
      const codes = await getRecoveryCodes();
      setBackupCodes(codes);
      setCurrentView("backup-codes");
      refreshUserDevices();
    } catch (e) {
      setError(e.message);
    }
  };

// ... implementation continues below

For Action—Based MFA

For action-based MFA, you need to create a token to authorize sensitive actions. This can be a single-use or persistent token.
// ... inside a component for action-based MFA

  const onOtpSubmitForAction = async (code: string) => {
    try {
      // Create a single-use token for the action
      const mfaToken = await authenticateDevice({ 
        code,
        createMfaToken: { singleUse: true } // or singleUse: false for persistent
      });
      console.log("MFA token created:", mfaToken);
      // Now you can perform the sensitive action
      setCurrentView("devices");
      refreshUserDevices();
    } catch (e) {
      setError(e.message);
    }
  };

// ... implementation continues below

6. Building the UI

Finally, build the UI to handle the different views in your MFA flow.
// ... inside TOTPMfaView component

  return (
    <div className="totp-mfa">
      {error && <div className="error">{error}</div>}
      
      {currentView === "devices" && (
        <div>
          <h3>TOTP Devices</h3>
          <pre>{JSON.stringify(userDevices, null, 2)}</pre>
          <button onClick={onAddDevice}>Add Device</button>
        </div>
      )}
      
      {currentView === "qr-code" && mfaRegisterData && (
        <QRCodeView 
          data={mfaRegisterData} 
          onContinue={() => setCurrentView("otp")} 
        />
      )}
      
      {currentView === "otp" && (
        <OTPView onSubmit={onOtpSubmit} />
      )}
      
      {currentView === "backup-codes" && (
        <BackupCodesView
          codes={backupCodes}
          onAccept={() => {
            completeAcknowledgement();
            setCurrentView("devices");
          }}
        />
      )}
    </div>
  );
};

<Accordion title="View Supporting Components">
  <Snippet file="mfa-totp-components.mdx" />
</Accordion>

## Advanced: Custom Recovery Flows

**Generate new recovery codes:**

You can force the generation of new recovery codes for a user. Note that this will invalidate any previously issued codes.

```tsx
const { getRecoveryCodes } = useMfa();

const generateNewCodes = async () => {
  const codes = await getRecoveryCodes(true); // force new codes
  setBackupCodes(codes);
};
Recovery code usage:
  • Users can use recovery codes instead of their device.
  • Each code is single-use.
  • Generate new codes after a user exhausts their supply.

Troubleshooting

  • Error: “401 Unauthorized” when adding second TOTP device: Only one TOTP device is supported per user. Delete the existing device before adding a new one.
  • QR code not displaying: Ensure you have the qrcode package installed: npm install qrcode @types/qrcode.
  • Recovery codes not working: Each recovery code is single-use. Generate new codes if all have been consumed.