Multiple MFA Methods

Privy allows users to register multiple MFA methods (e.g. SMS, TOTP, and passkeys) if they wish.

If a user already has one MFA method enabled, and wishes to enroll in another (or change their existing MFA method), Privy will require the user to complete MFA with their existing method in order to complete enrollment of the new, additional method.

When a user has multiple MFA methods enrolled, they will be given a choice of which method to use during verification:

Accessing enrolled methods

You can check which MFA methods are available for the current user through the user object:

const {user} = usePrivy();
console.log(user.mfaMethods);
// ['sms', 'totp', 'passkey'] for a user who has enrolled in all three methods

This can be useful for determining which options to present to users in your custom UIs.

Unenrolling MFA Methods

Default UI approach

When using Privy’s default UIs, users can remove MFA methods directly through the MFA management modal. Simply call the same method used for enrollment:

const {showMfaEnrollmentModal} = useMfaEnrollment();
// The same modal allows users to add or remove MFA methods
<button onClick={showMfaEnrollmentModal}>Manage MFA Methods</button>

Custom UI approach

For custom UIs, Privy provides methods to unenroll specific MFA methods:

import {useMfaEnrollment} from '@privy-io/react-auth';

export default function MfaUnenrollment() {
  const {unenrollWithSms, unenrollWithTotp, unenrollWithPasskey} = useMfaEnrollment();

  return (
    <div>
      <button onClick={unenrollWithSms}>Unenroll SMS</button>
      <button onClick={unenrollWithTotp}>Unenroll TOTP</button>
      <button onClick={unenrollWithPasskey}>Unenroll passkey</button>
    </div>
  );
}

When invoked, unenrollment methods will require the user to first complete MFA verification to confirm unenrollment. This ensures that only the authorized user can remove security methods.

Special considerations for passkeys

By default, unenrolling a passkey as an MFA method will also unlink it as a valid login method. You can modify this behavior to keep the passkey as a login method:

// Configure in the PrivyProvider
<PrivyProvider
  appId="your-privy-app-id"
  config={{
    ...theRestOfYourConfig,
    passkeys: {
      // Set this to `false` to keep the passkey as a login method after unenrolling from MFA
      shouldUnlinkOnUnenrollMfa: false
    }
  }}
>
  {/* your app's content */}
</PrivyProvider>

Error Handling for MFA Codes

When users are submitting MFA codes (both during enrollment and verification), several types of errors can occur:

  1. Incorrect code: The user entered the wrong MFA code
  2. Maximum attempts reached: The user has tried too many incorrect codes
  3. Timeout: The MFA code has expired and a new one must be requested

Helper functions for error types

Privy provides helper functions to identify the type of error:

import {
  errorIndicatesMfaVerificationFailed,
  errorIndicatesMfaMaxAttempts,
  errorIndicatesMfaTimeout
} from '@privy-io/react-auth'; // or '@privy-io/expo'

try {
  await submit({method: 'sms', mfaCode: userEnteredCode});
} catch (e) {
  if (errorIndicatesMfaVerificationFailed(e)) {
    // Wrong code - allow the user to try again
    setError('Incorrect code. Please try again.');
  } else if (errorIndicatesMfaMaxAttempts(e)) {
    // Too many attempts - request a new code
    setError('Too many failed attempts. Please request a new code.');
    resetMfaFlow();
  } else if (errorIndicatesMfaTimeout(e)) {
    // Code expired - request a new code
    setError('Your code has expired. Please request a new code.');
    resetMfaFlow();
  } else {
    // Some other error occurred
    setError('An unexpected error occurred. Please try again later.');
    console.error(e);
  }
}

Best practices for handling errors

  1. Clear error messaging: Provide specific feedback based on the error type
  2. Reset flow after max attempts: After the maximum number of attempts, clear state and allow the user to request a new code
  3. Timeouts: Inform users when codes expire and provide a way to request new ones
  4. Exponential backoff: Consider implementing a slight delay after multiple failures to discourage brute force attempts

Optimistic MFA Verification

For an optimal user experience, there are cases where you may want to prompt for MFA verification before it’s automatically required by the system. This is called “optimistic verification.”

An example use case: A user with TOTP enabled wants to add SMS as a second MFA method. Instead of waiting for the system to prompt for TOTP verification during the SMS enrollment, you can proactively ask for TOTP verification upfront for a smoother user flow.

Using promptMfa for optimistic verification

const {promptMfa} = useMfa();
const {initEnrollmentWithSms} = useMfaEnrollment();

const handleAddSmsMethod = async () => {
  // Proactively verify with existing MFA method first
  await promptMfa();

  // After successful verification, proceed with new method enrollment
  const phoneNumber = await getUserPhoneInput();
  await initEnrollmentWithSms({phoneNumber});

  // Continue with SMS enrollment flow...
};

The promptMfa method will trigger the MFA verification flow only if necessary (if the user doesn’t already have an active MFA token). If verification is successful, a token is cached for 15 minutes, and subsequent operations won’t require additional MFA verification.

Benefits of optimistic verification

  1. Improved UX: Consolidate authentication steps at logical points in the user journey
  2. Reduced friction: Avoid interrupting users in the middle of a multi-step process
  3. Predictable flow: Create a more controlled and predictable verification experience

Keep in mind that optimistic verification is entirely optional - the wallet will always require MFA when needed. This approach simply gives you more control over when and how the verification occurs.