Appearance
Implementing MFA with custom UIs
TIP
By default, Privy's wallet MFA feature will use Privy's UIs for enrolling users in MFA, managing MFA methods, and having users complete MFA to authorize signatures and transactions for the embedded wallet.
This guide is intended only for apps that would like to use wallet MFA with their own custom UIs, instead of Privy's defaults.
If you'd like to implement wallet MFA in your application using your own UIs, follow this guide. At a high-level, your app must set up two core user flows:
- Enrolling in MFA, with the user's desired authentication method(s)
- Completing MFA, when it is required by the embedded wallet
WARNING
Implementing wallet MFA with custom UIs is substantially more involved than integrating Privy's out-of-the-box wallet MFA feature. Make sure to consider the development trade-offs of using custom UIs over Privy defaults before finalizing on your approach!
Configuring MFA to be used with custom UIs
If you plan to use your own custom UIs for wallet MFA, set the mfa.noPromptOnMfaRequired
property of the PrivyProvider
to true
.
tsx
function MyApp({Component, pageProps}: AppProps) {
return (
<>
<PrivyProvider
appId={process.env.NEXT_PUBLIC_PRIVY_APP_ID}
config={{
mfa: {
// Defaults to 'false'
noPromptOnMfaRequired: true,
},
...insertTheRestOfYourConfig,
}}
>
<Component {...pageProps} />
</PrivyProvider>
</>
);
}
This will configure Privy to not show its default UIs for wallet MFA, and instead rely on your custom implementation (outlined below).
Enrolling in MFA
To enroll your users in MFA, use the useMfaEnrollment
hook from Privy. Currently, users can enroll in three MFA methods:
- SMS, where users authenticate with a 6-digit MFA code sent to their phone number
- TOTP, where users authenticate with a 6-digit MFA code from an authentication app, like Authy or Google Authenticator
- Passkey, where users verify with a previously registered passkey, generally through biometric authentication on their device
Follow the specific instructions below based on which method you'd like your user to enroll in!
SMS
To enroll your users in MFA with SMS, use the initEnrollmentWithSms
and submitEnrollmentWithSms
methods returned by the useMfaEnrollment
hook:
tsx
const {initEnrollmentWithSms, submitEnrollmentWithSms} = useMfaEnrollment();
First, initiate enrollment by prompting your user to enter the phone number they'd like to use for MFA. Then, call Privy's initEnrollmentWithSms
method. As a parameter to this initEnrollmentWithSms
, pass a JSON object with a phoneNumber
field that contains the user's provide phone number as a string.
tsx
// Prompt the user for their phone number
const phoneNumberInput = 'insert-phone-number-from-user';
// Send an enrollment code to their phone number
await initEnrollmentWithSms({phoneNumber: phoneNumberInput});
Once initEnrollmentWithSms
is called with a valid phoneNumber
, Privy will then send a 6-digit MFA enrollment code to the provided number. This method returns a Promise
that will resolve to void
if the code was successfully sent, or will reject with an error
if there was an error sending the code (e.g. invalid phone number).
Next, prompt the user to enter the 6-digit code that was sent to their phone number, and use the submitEnrollmentWithSms
method to complete enrollment of that phone number. As a parameter to submitEnrollmentWithSms
, you must pass a JSON object with both the original phoneNumber
that the user enrolled, and the mfaCode
they received at that number.
tsx
// Prompt the user for the code sent to their phone number
const mfaCodeInput = 'insert-mfa-code-received-by-user';
await submitEnrollmentWithSms({
phoneNumber: phoneNumberInput, // From above
mfaCode: mfaCodeInput,
});
This method returns a Promise
that will resolve to void
if the provided code is correct and the user has successfully enrolled this phone number in MFA, or will reject with an error
if there was an error. See the Handling errors with code submission section of this guide for how to best handle possible errors.
See an end-to-end example of enrolling users in MFA with SMS 👇
The component below serves as a reference implementation for how to enroll your users in MFA with SMS!
tsx
import {useMfaEnrollment} from '@privy-io/react-auth';
export default function MfaEnrollmentWithSms() {
const {initEnrollmentWithSms, submitEnrollmentWithSms} = useMfaEnrollment();
const [phoneNumber, setPhoneNumber] = useState<string | null>(null);
const [mfaCode, setMfaCode] = useState<string | null>(null);
const [pendingMfaCode, setPendingMfaCode] = useState<boolean>(false);
// Handler for when the user enters their phone number to enroll in MFA.
const onEnteredPhoneNumber = () => {
await initEnrollmentWithSms({phoneNumber: phoneNumber}); // Sends an MFA code to the `phoneNumber`
setPendingMfaCode(true);
}
// Handler for when the user enters the MFA code sent to their phone number.
const onEnteredMfaCode = () => {
await submitEnrollmentWithSms({phoneNumber: phoneNumber, mfaCode: mfaCode});
// See the "Handling errors with code submission" section of this guide
// for details on how to handle errors raised by `submitEnrollmentWithSms`
setPendingMfaCode(false);
}
// If no MFA code has been sent yet, prompt the user for their phone number to enroll
if (!pendingMfaCode) {
// Input field for the user to enter the phone number they'd like to enroll for MFA
return <>
<input placeholder='(555) 555 5555' onChange={(event) => setPhoneNumber(event.target.value)}/>
<button onClick={onEnteredPhoneNumber}>Enroll a Phone with MFA</button>
</>;
}
// Input field for the user to enter the MFA code sent to their phone number
return <>
<input placeholder='123456' onChange={(event) => setMfaCode(event.target.value)}/>
<button onClick={onEnteredMfaCode}>Submit Enrollment Code</button>
</>;
}
INFO
If your app has enabled SMS as a possible login method, users will not be able to enroll SMS as a valid MFA method.
SMS must be either be used as a login method to secure user accounts, or as an MFA method for additional security on the users' wallets.
TOTP
To enroll your users in MFA with TOTP, use the initEnrollmentWithTotp
and submitEnrollmentWithTotp
methods returned by the useMfaEnrollment
hook:
tsx
const {initEnrollmentWithTotp, submitEnrollmentWithTotp} = useMfaEnrollment();
First, initiate enrollment by calling Privy's initEnrollmentWithTotp
method with no parameters. This method returns a Promise
for an authUrl
and secret
that the user will need in order to complete enrollment.
tsx
const {authUrl, secret} = await initEnrollmentWithTotp();
Then, to have the user enroll, you can either:
- display the TOTP
authUrl
as a QR code to the user, and prompt them to scan it with their TOTP client (commonly, a mobile app like Google Authenticator or Authy) - allow the user to copy the TOTP
secret
and paste it into their TOTP client
TIP
You can directly pass in the authUrl
from above into a library like react-qr-code
to render the URL as a QR code to your user.
Once your user has successfully scanned the QR code, an enrollment code for Privy will appear within their TOTP client. Prompt the user to enter this code in your app, and call Privy's submitEnrollmentWithTotp
method. As a parameter to submitEnrollmentWithTotp
, pass a JSON object with an mfaCode
field that contains the MFA code from the user as a string.
tsx
const mfaCodeInput = 'insert-mfa-code-from-user-totp-app'; // Prompt the user for the code in their TOTP app
await submitEnrollmentWithTotp({mfaCode: mfaCodeInput});
This method returns a Promise
that will resolve to void
if the entered mfaCode
was correct, and will reject with an error otherwise. See the Handling errors with code submission section of this guide for how to best handle possible errors.
See an end-to-end example of enrolling users in MFA with TOTP 👇
The component below serves as a reference implementation for how to enroll your users in MFA with TOTP!
tsx
import {useMfaEnrollment} from '@privy-io/react-auth';
import QRCode from 'react-qr-code';
import {CopyableElement} from '../components/CopyableElement';
export default function MfaEnrollmentWithTotp() {
const {initEnrollmentWithTotp, submitEnrollmentWithTotp} = useMfaEnrollment();
const [totpAuthUrl, setTotpAuthUrl] = useState<string | null>(null);
const [totpSecret, setTotpSecret] = useState<string | null>(null);
const [mfaCode, setMfaCode] = useState<string | null>(null);
// Handler for when the user is ready to enroll in TOTP MFA
const onGenerateTotpUrl = async () => {
const {authUrl, secret} = await initEnrollmentWithTotp();
setTotpAuthUrl(authUrl);
setTotpSecret(secret);
}
// Handler for when the user enters the MFA code from their TOTP client
const onEnteredMfaCode = async () => {
await submitEnrollmentWithTotp({mfaCode: mfaCode});
// See the "Handling errors with code submission" section of this guide
// for details on how to handle errors raised by `submitEnrollmentWithTotp`
}
return(
<div>
{/* QR code for the user to scan */}
{totpAuthUrl && totpSecret ?
{/* If TOTP values have been generated... */}
<>
{/* ...show the user a QR code with the `authUrl` that they can scan... */}
<QRCode value={totpAuthUrl} />
{/* ...or give them the option to copy the `secret` into their TOTP client */}
<CopyableElement value={totpSecret} />
</> :
{/* Else, show a button to generate the totpAuthUrl */}
<button onClick={() => onGenerateTotpUrl()}>Show QR Code</button>
}
{/* Input field for the user to enter their MFA code */}
<p>Enter the code from your authenticator app below.</p>
<input placeholder='123456' onChange={(event) => setMfaCode(event.target.value)}/>
<button onClick={() => submitEnrollmentWithTotp({mfaCode: mfaCode})}>Submit Enrollment Code</button>
</div>
);
}
Passkey
To enroll your users in MFA with passkeys, use the initEnrollmentWithPasskey
and submitEnrollmentWithPasskey
methods returned by the useMfaEnrollment
hook:
tsx
const {initEnrollmentWithPasskey, submitEnrollmentWithPasskey} = useMfaEnrollment();
First, initiate enrollment by calling Privy's initEnrollmentWithPasskey
method with no parameters. This method returns a Promise
that will resolve to void
indicating success.
tsx
await initEnrollmentWithPasskey();
Then, to have the user enroll, you must call Privy's submitEnrollmentWithPasskey
method with a list of the user's passkey account credentialIds
. You can find this list by querying the user
's linkedAccounts
array for all accounts of type: 'passkey'
:
tsx
const {user} = usePrivy();
// ...
const credentialIds = user.linkedAccounts
.filter((account): account is PasskeyWithMetadata => account.type === 'passkey')
.map((x) => x.credentialId);
await submitEnrollmentWithPasskey({credentialIds});
This method returns a Promise
that will resolve to void
if the entered credentialIds
were valid and will reject with an error otherwise.
See an end-to-end example of enrolling users in MFA with passkeys 👇
The component below serves as a reference implementation for how to enroll your users in MFA with passkeys!
tsx
import {useMfaEnrollment} from '@privy-io/react-auth';
export default function MfaEnrollmentWithPasskey() {
const {user} = usePrivy();
const {initEnrollmentWithPasskey, submitEnrollmentWithPasskey} = useMfaEnrollment();
const handleEnrollmentWithPasskey = async () => {
await initEnrollmentWithPasskey();
const credentialIds = user.linkedAccounts
.filter((account): account is PasskeyWithMetadata => account.type === 'passkey')
.map((x) => x.credentialId);
await submitEnrollmentWithPasskey({credentialIds});
};
return (
<div>
<div>Enable your passkeys for MFA</div>
{user.linkedAccounts
.filter((account): account is PasskeyWithMetadata => account.type === 'passkey')
.map((account) => (
<div key={account.id}>{account.credentialId}</div>
))}
<button onClick={handleEnrollmentWithPasskey}>Enroll</button>
</div>
);
}
Enrolling multiple MFA methods
Privy allows users to register multiple MFA methods (e.g. SMS, TOTP, and passkeys) if they wish.
INFO
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.
To ensure your users can complete this flow if required, please make sure you have implemented the Completing MFA logic described below before allowing users to enroll in another MFA method.
This is the generic logic that allows Privy to invoke an MFA flow for your users whenever required (signatures, transactions, and in this case, enrolling a new MFA method). Thereafter, you should not need any other setup to allow users to enroll in additional MFA methods.
Unenrolling MFA methods
Privy also allows users to also delete MFA methods via the useMfaEnrollment
hook. You can use the returned methods:
unenrollWithSms
, to unenroll the user from SMS-based MFAunenrollWithTotp
, to uneroll the user from TOTP-based MFAunenrollWithPasskey
, to uneroll the user from passkey-based MFA
When invoked, unenrollment methods will require the user to first complete MFA verification, in order to confirm unenrollment. In kind, unenrolling MFA methods requires that you have implemented the Completing MFA logic described below.
These methods return a Promise
that resolves to void
if unenrollment was successful, or rejects with an error
if there was an issue with unenrollment (e.g. user did not complete MFA verification, or user did not have that MFA method enrolled).
tsx
import {useMfaEnrollment} from '@privy-io/react-auth';
export default function MfaUnenrollment() {
const {unenrollWithSms, unenrollWithTotp} = useMfaEnrollment();
return (
<div>
<button onClick={unenrollWithSms}>Unenroll SMS</button>
<button onClick={unenrollWithTotp}>Unenroll TOTP</button>
<button onClick={unenrollWithPasskey}>Unenroll passkey</button>
</div>
);
}
Completing MFA
Once users have successfully enrolled in MFA with Privy, they will be required to complete MFA whenever the private key for their embedded wallet must be used. This includes signing messages and transactions, recovering the embedded wallet on new devices, exporting the wallet's private key, and setting a password on the wallet.
To ensure users can complete MFA when required, your app must do the following:
- Set up a flow to guide the user through completing MFA when required.
- Register an event listener to configure Privy to invoke the flow from step (1) whenever MFA is required.
TIP
Once a user has completed MFA on a given device, they can continue to use the wallet on that device without needing to complete MFA for 15 minutes.
After 15 minutes have elapsed, Privy will require that the user complete MFA again to re-authorize use of the wallet's private key.
Guiding the user through MFA
To set up a flow to have the user complete MFA, use Privy's useMfa
hook.
tsx
const {init, submit, cancel} = useMfa();
This flow has three core components
- Requesting an MFA code be sent to the user's MFA method (
init
) - Submitting the MFA code provided by the user (
submit
) - Cancelling an in-progress MFA flow (
cancel
)
Requesting an MFA challenge
To request an MFA challenge for the current user, call the init
method from the useMfa
hook, passing the user's desired MFA method ('sms'
, 'totp'
, or 'passkey'
) as a parameter.
tsx
const selectedMfaMethod = 'sms'; // Must be 'sms', 'totp' or 'passkey'
await init(selectedMfaMethod);
The init
method will then prepare an MFA challenge for the desired MFA method, and returns a Promise
that resolves if the challenge was successfully created, and rejects with an error if there was an issue.
- If the MFA method is
'sms'
, the user will receive an SMS with their MFA code at the phone number they originally enrolled. - If the MFA method is
'totp'
, the user will receive the MFA code within their authenticator app. - If the MFA method is
'passkey'
,init
will return an object with options to pass to the native passkey system
Submitting the MFA verification
Once init
has resolved successfully, prompt the user to get their MFA code from their MFA method and to enter it within your app. Then, call the submit
method from useMfa
. As parameters to submit
, pass the MFA method being used ('sms'
or 'totp'
) and the MFA code that the user entered.
tsx
const mfaCode = 'insert-mfa-code-from-user';
await submit(selectedMfaMethod, mfaCode);
When invoked, submit
returns a Promise
that resolves to void
if the user has successfully completed MFA, or rejects with an error if there was an issue completing MFA. See the Handling errors with code submission section of this guide for how to best handle possible errors.
Cancelling the MFA flow
After init
has been called and the corresponding submit
call has not yet occurred, the user may cancel their in-progress MFA flow if they wish.
To cancel the current MFA flow, call the cancel
method from the useMfa
hook.
tsx
cancel();
See a recommended abstraction for guiding users to complete MFA 👇
To simplify the implementation, we recommend abstracting the logic above into a self-contained component that can be used whenever the user needs to complete an MFA flow.
For instance, you might write an MFAModal
component that allows the user to (1) select their desired method of their enrolled MFA methods, (2) request an MFA code, and (3) submit the MFA code to Privy for verification. A sample implementation is below:
tsx
import Modal from 'react-modal';
import {
useMfaEnrollment,
errorIndicatesMfaVerificationFailed,
errorIndicatesMfaMaAttempts,
errorIndicatesMfaTimeout,
MfaMethod,
} from '@privy-io/react-auth';
type Props = {
// List of available MFA methods that the user has enrolled in
mfaMethods: MfaMethod[];
// Boolean indicator to determine whether or not the modal should be open
isOpen: boolean;
// Helper function to open/close the modal */
setIsOpen: (isOpen: boolean) => void;
};
export const MFAModal = ({mfaMethods, isOpen, setIsOpen}: Props) => {
const {init, submit, cancel} = useMfa();
// Stores the user's selected MFA method
const [selectedMethod, setSelectedMethod] = useState<MfaMethod | null>(null);
// Stores the user's MFA code
const [mfaCode, setMfaCode] = useState('');
// Stores the options for passkey MFA
const [options, setOptions] = useState(null);
// Stores an error message to display
const [error, setError] = useState('');
// Helper function to request an MFA code for a given method
const onMfaInit = async (method: MfaMethod) => {
const response = await init(method);
setError('');
setSelectedMethod(method);
if (method === 'passkey') {
setOptions(response);
}
};
// Helper function to submit an MFA code to Privy for verification
const onMfaSubmit = async () => {
try {
if (selectedMethod === 'passkey') {
await submit(selectedMethod, options);
} else {
await submit(selectedMethod, mfaCode);
}
setSelectedMethod(null); // Clear the MFA flow once complete
setIsOpen(false); // Close the modal
} catch (e) {
// Handling possible errors with MFA code submission. See the "Handling errors
// with code submission" section of this guide for a more detailed explanation.
if (errorIndicatesMfaVerificationFailed(e)) {
setError('Incorrect MFA code, please try again.');
// Allow the user to re-enter the code and call `submit` again
} else if (errorIndicatesMfaMaxAttempts(e)) {
setError('Maximum MFA attempts reached, please request a new code.');
setSelectedMethod(null); // Clear the MFA flow to allow the user to trya gain
} else if (errorIndicatesMfaTimeout(e)) {
setError('MFA code has expired, please request a new code.');
setSelectedMethod(null); // Clear the MFA flow to allow the user to try again
}
}
};
// Helper function to clean up state when the user closes the modal
const onModalClose = () => {
cancel(); // Cancel any in-progress MFA flows
setIsOpen(false);
};
return (
<Modal isOpen={isOpen} onAfterClose={onModalClose}>
{/* Button for the user to select an MFA method and request an MFA code */}
{mfaMethods.map((method) => (
<button onClick={() => onMfaInit(method)}>Choose to MFA with {method}</button>
))}
{/* Input field for the user to enter their MFA code and submit it */}
{selectedMethod && selectedMethod !== 'passkey' && (
<div>
<p>Enter your MFA code below</p>
<input placeholder="123456" onChange={(event) => setMfaCode(event.target.value)} />
<button onClick={() => onMfaSubmit()}>Submit Code</button>
</div>
)}
{/* Display error message if there is one */}
{!!error.length && <p>{error}</p>}
</Modal>
);
};
Notice how the modal contains all logic for requesting the MFA code, submitting the MFA code, handling errors, and cancelling an in-progress MFA code.
Then, when your app needs to prompt a user to complete MFA, they can simply display this MFAModal
component and configure the mfaMethods
prop with the list of MFA methods that are available for the current user.
Registering an event listener
Once you've set up your app's logic for guiding a user to complete MFA, you lastly need to configure Privy to invoke this logic whenever MFA is required by the user's embedded wallet.
To set up this configuration, use Privy's useRegisterMfaListener
hook. As a parameter to useRegisterMfaListener
, you must pass a JSON object with an onMfaRequired
callback, described below.
onMfaRequired
Privy will invoke the onMfaRequired
callback you set whenever the user is required to complete MFA to use the embedded wallet. When this occurs, any use of the embedded wallet will be "paused" until the user has successfully completed MFA with Privy.
In this callback, you should invoke your app's logic for guiding through completing MFA (done via the useMfa
hook, as documented above). Within this callback, you can also access an mfaMethods
parameter that contains a list of available MFA methods that the user has enrolled in ('sms'
and/or 'totp'
and/or 'passkey'
).
Example registration
As an example, you might use the useRegisterMfaListener
within a component like below. This example uses the MFAModal
component implemented in the Prompting the user for MFA section of this guide.
tsx
import {useRegisterMfaListener, MfaMethod} from '@privy-io/react-auth';
import {MFAModal} from '../components/MFAModal';
export const MFAProvider = ({children}: {children: React.ReactNode}) => {
const [isMfaModalOpen, setIsMfaModelOpen] = useState(false);
const [mfaMethods, setMfaMethods] = useState<MfaMethod[]>([]);
useRegisterMfaListener({
// Privy will invoke this whenever the user is required to complete MFA
onMfaRequired: (methods) => {
// Update app's state with the list of available MFA methods for the user
setMfaMethods(methods);
// Open MFA modal to allow user to complete MFA
setIsMfaModalOpen(true);
},
});
return (
<div>
{/* This `MFAModal` component includes all logic for completing the MFA flow with Privy's `useMfa` hook */}
<MFAModal isOpen={isMfaModalOpen} setIsOpen={setIsMfaModalOpen} mfaMethods={mfaMethods} />
{children}
</div>
);
};
Optimistic MFA Verification
Once you've integrated MFA verification into your application, whenever MFA is required, the verify flow will be triggered. In some cases, for optimal user experience, you will want to prompt MFA verification before the onMfaRequired
callback fires.
INFO
Consider the case of a user registering SMS as their second MFA method. The ideal flow here would be to prompt MFA verification via the existing TOTP flow before asking the user for the phone number.
To accomplish this, you can use the promptMfa
method returned by the useMfa
hook. This will trigger the onMfaRequired
callback if and only if the user doesn't have an active MFA token that can be used for subsequent protected actions. By running this prior to a protected action, you can place the MFA verification flow at the ideal place.
tsx
const {initEnrollmentWithSms, submitEnrollmentWithSms} = useMfaEnrollment();
const {promptMfa} = useMfa();
// Prompt the user for MFA
await promptMfa();
// Prompt the user for their phone number
const phoneNumberInput = 'insert-phone-number-from-user';
// Send an enrollment code to their phone number
await initEnrollmentWithSms({phoneNumber: phoneNumberInput});
Handling errors with code submission
When both enrolling in and completing MFA, Privy sends a 6-digit code to the user's selected MFA method, that the user must submit to Privy in order to verify their identity. This is done via the following methods:
submitEnrollmentWithSms
from theuseMfaEnrollment
hook (for enrollment in SMS MFA)submitEnrollmentWithTotp
from theuseMfaEnrollment
hook (for enrollment in TOTP MFA)submitEnrollmentWithPasskey
from theuseMfaEnrollment
hook (for enrollment in passkey MFA)submit
from theuseMfa
hook (for completing MFA with both SMS and TOTP)
When submitting this MFA code, Privy may respond with an error if the code is incorrect, if the user has reached the maximum number of attempts for this MFA flow, or if the MFA flow has timed out. If the user enters in an incorrect code (e.g. by mistyping), the user is allowed to retry code submission up to a maximum of four attempts.
To simplify handling errors with MFA code submission, Privy provides the following helper functions to parse errors raised by the MFA code submission methods listed above. Each of these functions accepts the raw error
raised as a parameter, and returns a Boolean
indicating if the error meets a certain condition:
errorIndicatesMfaVerificationFailed
: indicates the user entered an incorrect MFA codeerrorIndicatesMfaMaxAttempts
indicates has reached the maximum number of attempts for this MFA flow, and that a new MFA code must be requested viainit
errorIndicatesMfaTimeout
indicates that the current MFA code has expired, and that a new MFA code must be requested viainit
As an example, to handle errors raised by submit
, you might use these helpers like so:
tsx
try {
// Errors from `submitEnrollmentWithSms` and `submitEnrollmentWithTotp` can be handled similarly
await submit('insert-mfa-code', 'insert-mfa-method');
} catch (e) {
if (errorIndicatesMfaVerificationFailed(e)) {
console.error('Incorrect MFA code, please try again.');
// Allow the user to re-enter the code and call `submit` again
} else if (errorIndicatesMfaMaxAttempts(e)) {
console.error('Maximum MFA attempts reached, please request a new code.');
// Allow the user to request a new code with `init`
} else if (errorIndicatesMfaTimeout(e)) {
console.error('MFA code has expired, please request a new code.');
// Allow the user to request a new code with `init`
}
}