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 totrue
, and also init the email verification recipe on the frontend and backend inREQUIRED
mode. - If you also want to enable first factor automatic account linking, see this link.
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, thengetFactorsSetupForUser
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 isotp-email
. Once they sign up, SuperTokens already knows the email for the user, when they are doing theotp-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.
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
'scheckAllowedToSetupFactorElseThrowInvalidClaimError
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 thecreateDevice
function from thetotp
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
andc
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
andc
in the session's MFA claim.
- fetches the MFA info that you can consume to know the
-
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 ofotp-sms
orotp-email
), then you can find the email to send the code to in theemails
orphoneNumbers
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 theemails
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.
- If the next array is
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.