Skip to main content

Initial setup

Overview

To integrate multi-factor authentication, MFA, in your application, you first need to decide on what factors you want to support and when to ask for them. The following guide shows you how to implement a basic setup while also covering customization methods.

Before you start

This feature is only available to paid users.

These instructions assume that you already have some knowledge of MFA. If you are not familiar with terms like authentication factors and challenges, please go through the MFA concepts page.

If you plan to use the otp-email factor as a form of email verification, you also need to initialize the emailverification recipe in REQUIRED mode on the backend. This configuration ensures that the email verification process passes only if the originally provided email has been verified.

Steps

What is your setup type?

1. Set up the backend

1.1 Enable account linking

MFA requires account linking to be active. You can enable it in the following way:

import SuperTokens, { User, RecipeUserId, } from "supertokens-node";
import { UserContext } from "supertokens-node/types";
import AccountLinking from "supertokens-node/recipe/accountlinking"
import { AccountInfoWithRecipeId } from "supertokens-node/recipe/accountlinking/types";
import { SessionContainerInterface } from "supertokens-node/recipe/session/types";

SuperTokens.init({
supertokens: {
connectionURI: "..."
},
appInfo: {
appName: "...",
apiDomain: "...",
websiteDomain: "..."
},
recipeList: [
// ...
AccountLinking.init({
shouldDoAutomaticAccountLinking: async (newAccountInfo: AccountInfoWithRecipeId & { recipeUserId?: RecipeUserId }, user: User | undefined, session: SessionContainerInterface | undefined, tenantId: string, userContext: UserContext) => {
if (session === undefined) {
// we do not want to do first factor account linking by default. To enable that,
// please see the automatic account linking docs in the recipe docs for your first factor.
return {
shouldAutomaticallyLink: false
};
}
if (user === undefined || session.getUserId() === user.id) {
// if it comes here, it means that a session exists, and we are trying to link the
// newAccountInfo to the session user, which means it's an MFA flow, so we enable
// linking here.
return {
shouldAutomaticallyLink: true,
shouldRequireVerification: false
}
}
return {
shouldAutomaticallyLink: false
};
}
}),
]
})
  • The above snippet enables auto account linking only during the second factor and not for the first factor login. This means that if a user has an email password account, and then they login via Google (with the same email), those two accounts are not linked. However, if the second factor for logging in is email or phone OTP, then that passwordless account links to the first factor login method of that session.
  • Notice that shouldRequireVerification: false configures account linking. It means that the second factor can connect to the first factor even though the first factor is not verified. If you want to do email verification of the first factor before setting up the second factor (for example if the first factor is email password, and the second is phone OTP), then you can set this boolean to true, and also init the email verification recipe on the frontend and backend in REQUIRED mode.
  • If you also want to enable first factor automatic account linking, see this link.
important

Account linking is a paid feature, and you need to generate a license key to enable it. Enabling the MFA feature also enables account linking automatically, meaning you don't need to check the account linking feature.

1.2 Configure the first factors

We start by initializing the MFA recipe on the backend and specifying the list of first factors using their factor IDs. You still have to initialize all the auth recipes in the recipeList, and configure them based on your needs.

For example, the code below initializes thirdparty, emailpassword and passwordless recipes and sets the firstFactor array to be ["emailpassword", "thirdparty"]. This means that email password and social login appear to the user as the first factor (using the thirdparty + emailpassword recipe), and passwordless serves as the second factor.

import supertokens from "supertokens-node";
import ThirdParty from "supertokens-node/recipe/thirdparty"
import EmailPassword from "supertokens-node/recipe/emailpassword"
import Passwordless from "supertokens-node/recipe/passwordless"
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth"

supertokens.init({
supertokens: {
connectionURI: "..."
},
appInfo: {
appName: "...",
apiDomain: "...",
websiteDomain: "..."
},
recipeList: [
// ...
ThirdParty.init({
//...
}),
EmailPassword.init({
//...
}),
Passwordless.init({
contactMethod: "EMAIL",
flowType: "USER_INPUT_CODE"
}),
MultiFactorAuth.init({
firstFactors: [
MultiFactorAuth.FactorIds.EMAILPASSWORD,
MultiFactorAuth.FactorIds.THIRDPARTY
]
})
]
})

Other combinations of first factors exists. For example, if you want passwordless as the first factor, then you would init the passwordless recipe and add "passwordless" in the firstFactors array.

1.3 Configure the second factor

This section explains how to configure SuperTokens such that a second factor is necessary for all users during sign up and during sign in. TOTP serves as an example for the second factor.

The following code snippet accomplishes this:

import SuperTokens, { User, RecipeUserId, } from "supertokens-node";
import { UserContext } from "supertokens-node/types";
import ThirdParty from "supertokens-node/recipe/thirdparty"
import EmailPassword from "supertokens-node/recipe/emailpassword"
import Passwordless from "supertokens-node/recipe/passwordless"
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth"
import totp from "supertokens-node/recipe/totp"
import AccountLinking from "supertokens-node/recipe/accountlinking"
import { AccountInfoWithRecipeId } from "supertokens-node/recipe/accountlinking/types";
import { SessionContainerInterface } from "supertokens-node/recipe/session/types";


SuperTokens.init({
supertokens: {
connectionURI: "..."
},
appInfo: {
appName: "...",
apiDomain: "...",
websiteDomain: "..."
},
recipeList: [
// ...
ThirdParty.init({
//...
}),
EmailPassword.init({
//...
}),
Passwordless.init({
contactMethod: "EMAIL",
flowType: "USER_INPUT_CODE"
}),
totp.init(),
MultiFactorAuth.init({
firstFactors: [
MultiFactorAuth.FactorIds.EMAILPASSWORD,
MultiFactorAuth.FactorIds.THIRDPARTY
],
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
getMFARequirementsForAuth: async function (input) {
return [MultiFactorAuth.FactorIds.TOTP]
}
}
}
}
})
]
})

In the above snippet, you configure email password and social login as the first factor, followed by TOTP as the second factor.

After sign in or sign up, SuperTokens calls the getMFARequirementsForAuth function to get a list of secondary factors for the user. The returned value determines the boolean value of v that's stored in the session's access token payload. If the returned factor is already completed (it's in the c object of the session's payload), then the value of v is true, else false.

In the above example, "totp" returns as a required factor for all users. However, you can also dynamically decide which factor to return based on the input arguments, which contains the User object, the tenantId, and the current session's access token payload. The default implementation of getMFARequirementsForAuth returns the set of factors specifically enabled for this user (see next section) or for the tenant (see later section).

The output of this function can be more complex than a string[]. You can also return an object which tells SuperTokens that any one of the factors must satisfy:


You can return an empty array from getMFARequirementsForAuth if you don't want any further MFA done for the current user.

1.4 Remove the second factor requirement Optional

Instead of configuring a factor for all users in your app, or for all users within a tenant, you may want to implement a flow in which users do MFA only if they have enabled it for themselves. Here, users may also want to choose what factors they would like to enable for themselves.

This flow allows users to configure their MFA preferences in the settings page in your app's frontend. A pre-built UI for this is not yet provided, but in this section, we explain the setup on the backend.

You want to start by creating an API that does session verification, and then enable the desired factor for the user. For example, if the user wants to enable TOTP, then you would call the following function in your API:

import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";

async function enableMFAForUser(userId: string) {
await MultiFactorAuth.addToRequiredSecondaryFactorsForUser(userId, MultiFactorAuth.FactorIds.TOTP)
}

The effect of the above function call is that in the default implementation of getMFARequirementsForAuth, the factors specifically enabled for the input user are considered. By default, if you add multiple factors for a user ID, then it would require them to complete any one of those secondary factors during login.

If you want to change the default behavior from "any one of" to something else (like "all of"), you can do this by overriding the getMFARequirementsForAuth function:

import supertokens from "supertokens-node";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth"

supertokens.init({
supertokens: {
connectionURI: "..."
},
appInfo: {
appName: "...",
apiDomain: "...",
websiteDomain: "..."
},
recipeList: [
// ...
MultiFactorAuth.init({
firstFactors: [
MultiFactorAuth.FactorIds.EMAILPASSWORD,
MultiFactorAuth.FactorIds.THIRDPARTY
],
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
getMFARequirementsForAuth: async function (input) {
return [{
allOfInAnyOrder: await input.requiredSecondaryFactorsForUser
}]
}
}
}
}
})
]
})

Once you call the addToRequiredSecondaryFactorsForUser function for a user, SuperTokens stores this preference in the user metadata JSON of the user. For example, if you add "totp" as a required secondary factor for a user, this preference is stored in the metadata JSON as:

{
"_supertokens": {
"requiredSecondaryFactors": ["totp"]
}
}

You can view this JSON on the user details page of the user management dashboard and modify it manually if you like.

To know the factors that a user has enabled, you can use the following function:

import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";

async function isTotpEnabledForUser(userId: string) {
let factors = await MultiFactorAuth.getRequiredSecondaryFactorsForUser(userId)
return factors.includes(MultiFactorAuth.FactorIds.TOTP)
}

Using the above function, you can build your settings page on the frontend which displays the existing enabled factors for the user. Allow users to enable or disable factors as they like.

Once you have enabled a factor for a user, you take them to that factor setup screen if they have not previously already setup the factor. To know if a factor is setup, you can call the following function (on the backend):

import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";

async function isTotpSetupForUser(userId: string) {
let factors = await MultiFactorAuth.getFactorsSetupForUser(userId)
return factors.includes(MultiFactorAuth.FactorIds.TOTP)
}

Or you can call the MFAInfo endpoint from the frontend which returns information indicating which factors have already been setup for the user and which not.

A factor is considered setup if the user has gone through that factor's flow at least once. For example, if the user has created and verified a TOTP device, only then does the getFactorsSetupForUser function return totp as part of the array. Likewise, if the user has completed otp-email or link-email once, only then do these factors become a part of the returned array. Let's take two examples:

  • The first time the user enables TOTP, then the result of getFactorsSetupForUser does not contain "totp". You should redirect the user to the TOTP setup screen. Once they add and verify a device, then getFactorsSetupForUser returns ["totp"] even if they later disable TOTP from the settings page and re-enable it.
  • Let's say that the first factor for a user is emailpassword, and the second factor is otp-email. Once they sign up, SuperTokens already knows the email for the user, when they are doing the otp-email step, then they are not asked to enter their email again (that is, an OTP is directly sent to them). However, until they actually complete the OTP flow, getFactorsSetupForUser does not return ["otp-email"] as part of the output.
caution

In the edge case that a factor is active for a user, but they sign out before setting it up, then when they login next, SuperTokens still asks them to complete the factor at that time. If SuperTokens doesn't have the required information (like no TOTP device for TOTP auth), then users need to set up a device at that point in time.

If you would like to change how this works and only want users to set up their factor via the settings page, and not during sign in, you can do this by overriding the getMFARequirementsForAuth function, which takes as an input the list of factors that are setup for the current user.

The subsequent sections in this doc walk through frontend setup, and also specific examples of common MFA flows.

2. Set up the frontend

What type of UI are you using?

The pre-built UI provides support for the following MFA methods:

  • TOTP
  • Email / phone OTP

If you want other types of MFA (like magic links, or password), please consider checking out the custom UI second.

We start by initialising the MFA recipe on the frontend and providing the list of first factors as shown below:

import supertokens from "supertokens-auth-react"
import EmailPassword from "supertokens-auth-react/recipe/emailpassword"
import Passwordless from "supertokens-auth-react/recipe/passwordless"
import MultiFactorAuth from "supertokens-auth-react/recipe/multifactorauth"

supertokens.init({
appInfo: {
appName: "...",
apiDomain: "...",
websiteDomain: "...",
},
recipeList: [
EmailPassword.init( /* ... */),
Passwordless.init({
contactMethod: "EMAIL_OR_PHONE",
}),
MultiFactorAuth.init({
firstFactors: [
MultiFactorAuth.FactorIds.EMAILPASSWORD,
MultiFactorAuth.FactorIds.THIRDPARTY
]
})
]
})

In the above snippet, thirdparty and email password are configured as first factors. The second factor is determined on the backend, based on the boolean value of v in the MFA claim in the session. If the v is false in the session, it means that there are still factors pending before the user has completed login. In this case, the frontend SDK calls the MFAInfo endpoint (see more about this later) on the backend which returns the list of factors (string[]) that the user must complete next. For example:

  • If the next array is ["otp-email"], then the user sees the enter OTP screen for the email associated with the first factor login.
  • If the n array has multiple items, the user sees a factor chooser screen using which they can decide which factor they want to continue with.
  • If the next is empty, it means that:
    • A misconfiguration exists on the backend. This would show an access denied screen to the user. OR;
    • Another claim needs to satisfy first (like email verification), before the next MFA challenge can display. This can happen if you configure the backend's checkAllowedToSetupFactorElseThrowInvalidClaimError function to not allow a factor setup until the email has been verified.

If you notice, in the above code snippet, Passwordless.init is also included, and this handles cases where the second factor is otp-email or otp-sms. For TOTP, a different recipe is used as shown later in this guide.

Usage with email verification

If you are also requiring email verification, the user must verify the email first, and then all the MFA challenges. For example, if the user has email password as the first factor, and then TOTP as a second factor, SuperTokens prompts the user to do email password login, followed by email verification, followed by TOTP.

To switch the order such that email verification happens after the secondary factors of MFA, follow the next code snippet.

import supertokens from "supertokens-auth-react"
import EmailVerification from "supertokens-auth-react/recipe/emailverification";
import Session from "supertokens-auth-react/recipe/session";

supertokens.init({
appInfo: {
appName: "...",
apiDomain: "...",
websiteDomain: "...",
},
recipeList: [
// other recipes...
EmailVerification.init({
mode: "REQUIRED",
}),
Session.init({
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
getGlobalClaimValidators: (input) => {
let emailVerificationClaimValidator = input.claimValidatorsAddedByOtherRecipes.find(v => v.id === EmailVerification.EmailVerificationClaim.id)!;
let filteredValidators = input.claimValidatorsAddedByOtherRecipes.filter(v => v.id !== EmailVerification.EmailVerificationClaim.id);
return [...filteredValidators, emailVerificationClaimValidator];
}
}
}
}
})
]
})

In the snippet above, the getGlobalClaimValidators function in the Session recipe is overridden to add the email verification validator at the end of the returned validators array. This ensures that post the first factor sign up, the first validator that fails is the MFA one which redirects the user to complete the MFA factors.

Handle misconfigurations

There can be situations of misconfigurations. For example you may have enabled otp-email for a user as a secondary factor, but did not add Passwordless (or ThirdPartyPasswordless) in the recipeList on the frontend. In such (and similar) situations, the pre-built UI on the frontend throws an error which is sent to the error boundary of your app. The way to solve these errors is to recheck the recipeList on the frontend, and make sure that it has all the recipes initialized that are necessary for any factor configured on the backend.

The access denied screen

Sometimes, users may end up seeing an access denied screen during the login flow. This appears if there is a 500 (backend sends a 500 status code) error during the MFA flow for API calls that are automatically initiated (without user action). For example:

  • When the user wants to setup a new TOTP device, the pre-built UI calls the createDevice function from the totp recipe on page load, and if that fails, users see the access denied screen asking them to retry.
  • When the user needs to complete an OTP email factor, and if the API call to send an email (which starts on page load) fails, then users see the access denied screen asking them to retry.

You can override this component in the following way:

Do you use react-router-dom?

References

The MFA info endpoint

This is an important endpoint which can be utilized to:

  • Know which factors are pending for the user (referred to as the the next array in the documentation).
  • Update the v and c values in the MFA claim.
  • Get a list of all factors that are already setup for the session user.
  • For each factor, get a list of emails / phone numbers that can be utilized for that factor.

Our pre-built UI uses this API automatically, but you can also always call this API manually if you are building a custom UI:

import MultifactorAuth from "supertokens-web-js/recipe/multifactorauth"
import Session from "supertokens-web-js/recipe/session"

async function fetchMFAInfo() {
if (await Session.doesSessionExist()) {
try {
let mfaInfo = await MultifactorAuth.resyncSessionAndFetchMFAInfo()
let factorEmails = mfaInfo.emails;
let factorPhoneNumbers = mfaInfo.phoneNumbers;

let emailsForOTPEmail = factorEmails["otp-email"];
let phoneNumbersForOTPSms = factorEmails["otp-sms"];
let isTotpSetup = mfaInfo.factors.alreadySetup.includes("totp");
let isOTPEmailSetup = mfaInfo.factors.alreadySetup.includes("otp-email");
let isOTPSmsSetup = mfaInfo.factors.alreadySetup.includes("otp-sms");
let next = mfaInfo.factors.next;

let factorsAllowedToBeSetup = mfaInfo.factors.allowedToSetup;
} catch (err: any) {
if (err.isSuperTokensGeneralError === true) {
// this may be a custom error message sent from the API by you.
window.alert(err.message);
} else {
window.alert("Oops! Something went wrong.");
}
}
} else {
throw new Error("Illegal function call: For first factor setup, you do not need to call this function")
}
}
  • In the above code snippet, the list of factors which the user must complete next (in the next array) is retrieved along with all the relevant information to know what state each factor is in to decide if the user should be prompted to setup the factor (for example create a new TOTP device), or solve the auth challenge instead (for example, showing the enter TOTP screen).
  • The function is called resyncSessionAndFetchMFAInfo because it does two things:
    • fetches the MFA info that you can consume to know the next array and what state each factor is in.
    • resynchronizes the value of the v and c in the session's MFA claim.
  • The structure of the raw JSON response is as follows:

    {
    "status": "OK",
    "factors": {
    "alreadySetup": ["totp", "otp-email", "..."],
    "allowedToSetup": ["otp-sms", "otp-email", "..."],
    "next": ["otp-sms", "..."]
    },
    "emails": {
    "otp-email": ["user1@example.com", "user2@example.com"],
    "link-email": ["user1@example.com", "user2@example.com"],
    },
    "phoneNumbers": {
    "otp-sms": ["+1234567890", "+1098765432"],
    "link-phone": ["+1234567890", "+1098765432"],
    },
    }
    • factors.alreadySetup is an array that contains all factors that have been setup by the user. If the current factor is a part of this array, it means that you can directly take the user to the factor challenge screen. If your factor depends on an email or phone number (like in the case of otp-sms or otp-email), then you can find the email to send the code to in the emails or phoneNumbers object in the response with the key as the current factor ID.
    • factors.allowedToSetup is an array that contains all factors that the user can setup at this point. This is not that useful during the sign in process, but may be useful post sign in if you want to know what are the factors that the user can setup at any point in time.
    • emails is an object in which the key are all the factor IDs supported by SuperTokens (and any custom factor ID added by you). The values against each of the keys is a list of emails that can be utilized to complete the factor. The first email (index 0) in the list is the preferred email to use for the factor. The order is determined based on the first factor chosen by the user, and if the factor was already setup or not.

    If the array is empty, it means that there is no email associated with the user for that factor. This can happen only if the factor was not already setup. In this case, you should take the user to a screen to ask them to first enter an email, and then to the challenge screen.

    The flow is further explained in the common flows guide later on.

    • phoneNumbers is similar to the emails object, except that it contains phone numbers for factors that are dependent on phone numbers.
    • The factors.next array determines the list of factors which the user must completed next. For example:
      • If the next array is ["otp-email"], then the user sees the enter OTP screen for the email associated with the first factor login.
      • If the n array has multiple items:
        • For the pre-built UI, the user sees a factor chooser screen using which they can decide which factor they want to continue with.
        • For custom UI, you would need to make this screen on your own.
      • If the next is empty, it means that:
        • A misconfiguration exists on the backend. This would show an access denied screen to the user. OR;
        • Another claim needs to satisfy first (like email verification), before the next MFA challenge can display. This can happen if you configure the checkAllowedToSetupFactorElseThrowInvalidClaimError function, on the backend, to not allow a factor setup until the email is verified.

Handle support cases

Some situations exist in which users may be locked out of their accounts and would need you to do certain steps to unlock their accounts. These cases are:

If you are using otp-email MFA factor as a form of email verification, you should also have emailverification recipe initialised in REQUIRED mode on the backend (no need to add it on the frontend since users won't see that UI). This is for security reasons wherein during the sign up process, when asking for the otp-email challenge, the email the OTP is sent to is determined on the frontend (automatically). In this case, the following scenario is possible:

  • User signs up with email A
  • An email OTP challenge is displayed to the user, and an OTP to email A is sent automatically.
  • The user manually calls the OTP create code API with email B and their session token, and verifies the OTP via a call to the consume code API.
  • The user refreshes the page and the otp-email challenge is complete.

Of course, this is not the desired flow when you want to use otp-email as a form of email verification. To prevent this, you should have the emailverification recipe initialised in REQUIRED mode on the backend. This ensures that the email verification claim validator only passes if the email that's verified is the one from the first factor (email A).

The above case is only possible during sign up, and not sign in. :::

Security considerations

SuperTokens enforces that a user has completed all the required factors by keeping track of and checking them in the user's access token payload.

  • If a user is required to complete a MFA challenge, for example TOTP, if they already have a verified TOTP device, they cannot setup any other factor before completing this factor challenge, and if they do not yet have a verified TOTP device, then the only action they are allowed to take is to create a new TOTP device. This ensures that a user cannot bypass the MFA challenges of the current or future step.
  • When a user creates a new TOTP device, it cannot be utilized unless they first verify it by entering the initial TOTP code.
  • If the email of the 2nd factor login method is not confirmed, by default, it is not allowed to be setup or used as a 2nd factor, unless the session user has a login method that has the same email which is verified.
  • A fixed number of times (5 times by default) a user can enter an invalid TOTP code, after which they have to wait for 15 minutes before trying again. This timeout and the max attempts count can be modified in the core configuration.
  • During sign up (not sign in), for email / SMS OTP challenge, the email / SMS that the OTP is sent to is determined by the frontend. This is intentional because it allows you to create a flow in which the email the OTP is sent to may not be the same as the login method of the first factor. However, from a security point of view, it allows a malicious actor to send an OTP to a different email / phone number than the first factor's phone or email. This is not an issue if you are using email OTP as a method for email verification because the email verification recipe checks that the email of the first factor is verified, and in the case of the malicious user, the email of the first factor won't be verified because they entered a different email for otp-email challenge.

See also