Skip to main content

Setting up the 2nd factor

caution
  • SuperTokens is not yet optimised for 2FA implementation, so you have to add a lot of customisations for it to work. We are working on improving the development experience for 2FA as well as adding more factors like TOTP. Stay tuned.
  • A demo app that uses the pre built UI can be found on our GitHub.

1) Initialisation#

We will be using the Passwordless recipe with SMS OTP as the second factor. You can follow the recipe's backend quick setup guide to configure a different method as well (for example with email magic links).

The Passwordless.init function should look something like this:

import supertokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
import ThirdPartyEmailPassword from"supertokens-node/recipe/thirdpartyemailpassword";
import Passwordless from "supertokens-node/recipe/passwordless"

supertokens.init({
framework: "express",
supertokens: {
connectionURI: "",
apiKey: "",
},
appInfo: {
// learn more about this on https://supertokens.com/docs/thirdpartyemailpassword/appinfo
appName: "<YOUR_APP_NAME>",
apiDomain: "<YOUR_API_DOMAIN>",
websiteDomain: "<YOUR_WEBSITE_DOMAIN>",
apiBasePath: "/auth",
websiteBasePath: "/auth"
},
recipeList: [
Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE"
}),
ThirdPartyEmailPassword.init({/*...*/}),
Session.init({/*Override from previous step*/})
]
});

The above will expose all the APIs to the frontend that can be used to create and verify the OTP.

2) Saving the user's phone number post second factor auth#

During sign up, once the user has completed the second factor, we want to save their phone number against their profile. For this, we will use the UserMetadata recipe.

important

Make sure to add the User Metadata in the recipe list.

The passwordless recipe will create a new userId for the user against which it will save the phone number. We can associate the passwordless userId with the userId of the first factor, and this way, we associate a phone number to the user:

import Session from "supertokens-node/recipe/session";
import UserMetadata from "supertokens-node/recipe/usermetadata";
import Passwordless from "supertokens-node/recipe/passwordless";

Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
override: {
apis: (oI) => {
return {
...oI,
// this API is called when the user enters the OTP
consumeCodePOST: async function (input) {
// - We should already have a session here since this is called after first factor login
// - We set the claims to check to be [] here, since this needs to be callable
// without the second factor completed
let session = await Session.getSession(input.options.req, input.options.res, {
overrideGlobalClaimValidators: () => [],
});

let resp = await oI.consumeCodePOST!(input);

if (resp.status === "OK") {
// OTP verification was successful. We can now associate
// the passwordless user ID with the thirdpartyemailpassword
// user ID, so that later on, we can fetch the phone number.
await UserMetadata.updateUserMetadata(
session!.getUserId(), // this is the userId of the first factor login
{
passwordlessUserId: resp.user.id,
}
);
}

return resp;
},
};
},
}
})

3) Updating the session post second factor auth#

We also want to change the session's payload to indicate that the user has completed the second factor. We do this by setting the SecondFactorClaim to true in the session.

We also have to be careful about not creating a new session after the second factor auth is completed. By default, the passwordless recipe will create a new session on successul verification, overwriting the older one. We can prevent this, by using the userContext feature:

import Session from "supertokens-node/recipe/session";
import UserMetadata from "supertokens-node/recipe/usermetadata";
import Passwordless from "supertokens-node/recipe/passwordless";

Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
override: {
apis: (oI) => {
return {
...oI,
// this API is called when the user enters the OTP
consumeCodePOST: async function (input) {
// A session should already exist since this should be called after the first factor is completed.
// We set the claims to check to be [] here, since this needs to be callable
// without the second factor completed
let session = await Session.getSession(input.options.req, input.options.res, {
overrideGlobalClaimValidators: () => [],
});

// we add the existing session to the user context so that the createNewSession
// function doesn't create a new session
input.userContext.session = session;

let resp = await oI.consumeCodePOST!(input);

if (resp.status === "OK") {
// OTP verification was successful.
// We can now set the SecondFactorClaim in the session to true.
// the user has access to API routes and the frontend UI
await resp.session.setClaimValue(SecondFactorClaim, true);

// We can now associate
// the passwordless user ID with the thirdpartyemailpassword
// user ID, so that later on, we can fetch the phone number.
await UserMetadata.updateUserMetadata(
session!.getUserId(), // this is the userId of the first factor login
{
passwordlessUserId: resp.user.id,
}
);
}

return resp;
},
};
},
}
})

Session.init({
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
/* This function is called after signing in or signing up via the first factor */
createNewSession: async function (input) {
if (input.userContext.session !== undefined) {
/**
* This will be true for the second factor login.
* So instead of creating a new session, we return the already existing one.
*/
return input.userContext.session;
}
return originalImplementation.createNewSession({
...input,
accessTokenPayload: {
...input.accessTokenPayload,
...(await SecondFactorClaim.build(input.userId, input.tenantId, input.userContext)),
},
});
},
};
},
},
})

4) Validating the phone number#

By default, the Passwordless API for sending an OTP (createCodePOST) sends the OTP to the input phone number, and if we don't modify that, the attack below is be possible:

  • Alice (user) signs up using a weak password and their phone number.
  • Mallory (attacker) successfully guesses Alice's password and queries the OTP sending API manually, to inject her phone number for the second factor auth.
  • OTP is sent to Mallory's phone number and she can pass the second factor challenge.

To make it secure, we override the createCodePOST API and check that the input phone number is the same as the phone number associated with the user. If it's not the same, we throw an error, and if it is the same, we continue:

import Session from "supertokens-node/recipe/session";
import UserMetadata from "supertokens-node/recipe/usermetadata";
import Passwordless from "supertokens-node/recipe/passwordless";

Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
override: {
apis: (oI) => {
return {
...oI,
/*This API is called to send an OTP*/
createCodePOST: async function (input) {
/**
* We want to make sure that the OTP being generated is for the
* same number that belongs to this user.
*/

// A session should already exist since this should be called after the first factor is completed.
// We set the claims to check to be [] here, since this needs to be callable
// without the second factor completed
let session = await Session.getSession(input.options.req, input.options.res, {
overrideGlobalClaimValidators: () => [],
});

// We try and get the phone number associated with this user. It will be
// defined if this is a sign in attempt, in which case, we will check that
// it is equal to the input phone number
let userMetadata = await UserMetadata.getUserMetadata(session!.getUserId());
let phoneNumber: string | undefined = undefined;
if (userMetadata.metadata.passwordlessUserId !== undefined) {
// the flow will come here during a login attempt, since we
// associate the passwordless userId to the user on sign up
let passwordlessUserInfo = await Passwordless.getUserById({
userId: userMetadata.metadata.passwordlessUserId as string,
userContext: input.userContext,
});
phoneNumber = passwordlessUserInfo?.phoneNumber;
}

if (phoneNumber !== undefined) {
// this means we found a phone number associated to this user.
// we will check if the input phone number is the same as this one.
if (!("phoneNumber" in input) || input.phoneNumber !== phoneNumber) {
throw new Error("Input phone number is not the same as the one saved for this user");
}
}

return oI.createCodePOST!(input);
},
consumeCodePOST: async function (input) {
/*...Modifications from previous step */
let resp = await oI.consumeCodePOST!(input);
/*...Modifications from previous step */
return resp;
},
};
},
}
})

5) Storing the user's phone number in the session#

When the session is first created (after the first factor is completed), we store the user's phone number in the session (if it exists), so that the frontend can call the createCodePOST API (to intiate the second factor challenge) without asking the user for their phone number again.

We do this by modifying the createNewSession function in the Session.init call:

import Session from "supertokens-node/recipe/session";
import UserMetadata from "supertokens-node/recipe/usermetadata";
import Passwordless from "supertokens-node/recipe/passwordless";

Session.init({
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
/* This function is called after signing in or signing up via the first factor */
createNewSession: async function (input) {
if (input.userContext.session !== undefined) {
/**
* This will be true for the second factor login.
* So instead of creating a new session, we return the already existing one.
*/
return input.userContext.session;
}

// we first get the passwordless userId associated with this user
// using the UserMetadata recipe
let userMetadata = await UserMetadata.getUserMetadata(input.userId);
let phoneNumber: string | undefined = undefined;
if (userMetadata.metadata.passwordlessUserId !== undefined) {
// We get the phone number associated with the passwordless userId.
let passwordlessUserInfo = await Passwordless.getUserById({
userId: userMetadata.metadata.passwordlessUserId as string,
userContext: input.userContext,
});
phoneNumber = passwordlessUserInfo?.phoneNumber;
}

return originalImplementation.createNewSession({
...input,
accessTokenPayload: {
...input.accessTokenPayload,
...(await SecondFactorClaim.build(input.userId, input.tenantId, input.userContext)),
phoneNumber,
},
});
},
};
},
},
})

We can then further modify the customisation in step (4) to simply read from the session's payload making it more efficient:

import Session from "supertokens-node/recipe/session";
import UserMetadata from "supertokens-node/recipe/usermetadata";
import Passwordless from "supertokens-node/recipe/passwordless";

Passwordless.init({
flowType: "USER_INPUT_CODE",
contactMethod: "PHONE",
override: {
apis: (oI) => {
return {
...oI,
/*This API is called to send an OTP*/
createCodePOST: async function (input) {
/**
* We want to make sure that the OTP being generated is for the
* same number that belongs to this user.
*/

// A session should already exist since this should be called after the first factor is completed.
// We remove claim checking here, since this needs to be callable without the second factor completed
let session = await Session.getSession(input.options.req, input.options.res, {
overrideGlobalClaimValidators: () => [],
});

let phoneNumber: string = session!.getAccessTokenPayload().phoneNumber;

if (phoneNumber !== undefined) {
// this means we found a phone number associated to this user.
// we will check if the input phone number is the same as this one.
if (!("phoneNumber" in input) || input.phoneNumber !== phoneNumber) {
throw new Error("Input phone number is not the same as the one saved for this user");
}
}

return oI.createCodePOST!(input);
},
consumeCodePOST: async function (input) {
/*...Modifications from previous step */
let resp = await oI.consumeCodePOST!(input);
/*...Modifications from previous step */
return resp;
},
};
},
}
})