Using a custom UI
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) First factor recipe initStart by following the recipe guide for first factor login. To continue building our example app, we will use the thirdpartyemailpassword recipe as the first factor.
After following the frontend quick setup section, you should have the following supertokens.init
:
- Via NPM
- Via Script Tag
import SuperTokens from 'supertokens-web-js';
import Session from 'supertokens-web-js/recipe/session';
import ThirdPartyEmailPassword from 'supertokens-web-js/recipe/thirdpartyemailpassword';
SuperTokens.init({
appInfo: {
apiDomain: "<YOUR_API_DOMAIN>",
apiBasePath: "/auth",
appName: "...",
},
recipeList: [
Session.init(),
ThirdPartyEmailPassword.init()
],
});
supertokens.init({
appInfo: {
apiDomain: "<YOUR_API_DOMAIN>",
apiBasePath: "/auth",
appName: "...",
},
recipeList: [
supertokensSession.init(),
supertokensThirdPartyEmailPassword.init()
],
});
From here on, you can continue to build out the first factor's login form using the functions exposed from the supertokens-web-js
SDK. See the thirdpartyemailpassword recipe guide for more information about the flows and functions.
#
2) Second factor recipe initFor the second factor, we will be using the passwordless recipe. After following the frontend quick setup section, you should have the following supertokens.init
:
- Via NPM
- Via Script Tag
import SuperTokens from 'supertokens-web-js';
import Session from 'supertokens-web-js/recipe/session';
import ThirdPartyEmailPassword from 'supertokens-web-js/recipe/thirdpartyemailpassword';
import Passwordless from 'supertokens-web-js/recipe/passwordless';
SuperTokens.init({
appInfo: {
apiDomain: "<YOUR_API_DOMAIN>",
apiBasePath: "/auth",
appName: "...",
},
recipeList: [
Session.init(),
ThirdPartyEmailPassword.init(),
Passwordless.init(),
],
});
supertokens.init({
appInfo: {
apiDomain: "<YOUR_API_DOMAIN>",
apiBasePath: "/auth",
appName: "...",
},
recipeList: [
supertokensSession.init(),
supertokensThirdPartyEmailPassword.init(),
supertokensPasswordless.init()
],
});
You can use the passwordless recipe function to build the second factor UI.
#
3) Reading 2FA completion information from the session for routingYou will want to handle the routing of webapp to make sure that the correct login factor is being shown. This can be done by reading the session information.
#
Checking if the first factor login should be shown- Via NPM
- Via Script Tag
import Session from 'supertokens-web-js/recipe/session';
async function shouldShowFirstFactor() {
return !(await Session.doesSessionExist());
}
async function shouldShowFirstFactor() {
return !(await supertokensSession.doesSessionExist());
}
If a session does not exist, this means that the user has not completed the first factor. In this case, you want to route them to the thirdpartyemailpassword login screen.
#
Checking if the second factor login should be shown- Via NPM
- Via Script Tag
import Session, { BooleanClaim } from 'supertokens-web-js/recipe/session';
export const SecondFactorClaim = new BooleanClaim({
id: "2fa-completed",
refresh: async () => {
// no-op
},
});
async function shouldShowSecondFactor() {
if(await shouldShowFirstFactor()) {
return false;
}
return !(await Session.getClaimValue({ claim: SecondFactorClaim }));
}
async function shouldShowFirstFactor() {
return !(await Session.doesSessionExist());
}
// This could be moved into a separate file...
export const SecondFactorClaim = new supertokensSession.BooleanClaim({
id: "2fa-completed",
refresh: async () => {
// This is something we have no way of refreshing, so this is a no-op
},
});
async function shouldShowSecondFactor() {
if(await shouldShowFirstFactor()) {
return false;
}
if (await supertokensSession.getClaimValue({ claim: SecondFactorClaim })) {
return false;
}
return true;
}
async function shouldShowFirstFactor() {
return !(await supertokensSession.doesSessionExist());
}
- If a session does not exist, it means that the user has not finished the first factor yet.
- If a session exists, but the
SecondFactorClaim
value istrue
, it means that the user has finished both the factors. - Otherwise the user has finished the first factor, but not the second one.
#
Protecting a website route that requires both the factorsYou can check if a user has finished both the login factors using the two functions above:
async function areBothLoginFactorsCompleted(): Promise<boolean> {
return !(await shouldShowFirstFactor()) && !(await shouldShowSecondFactor())
}
areBothLoginFactorsCompleted().then(async (bothFactorsCompleted) => {
if (bothFactorsCompleted) {
// update state to show UI
} else {
if (await shouldShowFirstFactor()) {
// redirect user to first factor
} else {
// redirect user to second factor screen
}
}
})
#
4) Getting the user's phone number for the second factorOnce the user has finished the sign up process, we save their phone number in the session (as seen in the backend setup steps). This can be accessed on the frontend to send the OTP to the user without asking them to re-enter their phone after sign in:
- Via NPM
- Via Script Tag
import Session from 'supertokens-web-js/recipe/session';
async function getUsersPhoneNumber(): Promise<string | undefined> {
if (!(await Session.doesSessionExist())) {
// the user has not finished the first factor.
return undefined;
}
let accessTokenPayload = await Session.getAccessTokenPayloadSecurely();
if (accessTokenPayload.phoneNumber === undefined) {
// this means that the user is still signing up, or it means that the user
// had previously tried to sign up, but didn't complete the second factor step,
// and has now just signed in.
// In this case, we should ask the user to enter their phone number.
return undefined;
}
// An OTP can be sent to this phone for the second factor.
// No need to ask the user to enter their phone number again.
return accessTokenPayload.phoneNumber;
}
async function getUsersPhoneNumber(): Promise<string | undefined> {
if (!(await supertokensSession.doesSessionExist())) {
// the user has not finished the first factor.
return undefined;
}
let accessTokenPayload = await supertokensSession.getAccessTokenPayloadSecurely();
if (accessTokenPayload.phoneNumber === undefined) {
// this means that the user is still signing up, or it means that the user
// had previously tried to sign up, but didn't complete the second factor step,
// and has now just signed in.
// In this case, we should ask the user to enter their phone number.
return undefined;
}
// An OTP can be sent to this phone for the second factor.
// No need to ask the user to enter their phone number again.
return accessTokenPayload.phoneNumber;
}
#
5) Implementing logoutIf the user has completed both the factors, implementing the sign out feature can be done by:
- Via NPM
- Via Script Tag
import Session from 'supertokens-web-js/recipe/session';
async function signOut() {
await Session.signOut();
// redirect the user to the first factor login screen
}
async function signOut() {
await supertokensSession.signOut();
// redirect the user to the first factor login screen
}
You should also implement a sign out button on the second factor screen, otherwise the user would be in a stuck state if they are unable to complete the second factor. To do this, you will need to call the signOut
function as well as a function to clear the passwordless login state:
- Via NPM
- Via Script Tag
import Session from 'supertokens-web-js/recipe/session';
import Passwordless from 'supertokens-web-js/recipe/passwordless';
async function signOut() {
if (await shouldShowSecondFactor()) {
// this means we are on the second factor screen now.
// calling the function below clears the login attempt info that is
// saved on the browser during passwordless login. This is needed so that
// future login attempts are not affected by the current one.
await Passwordless.clearLoginAttemptInfo();
}
await Session.signOut();
// redirect the user to the first factor login screen
}
async function signOut() {
if (await shouldShowSecondFactor()) {
// this means we are on the second factor screen now.
// calling the function below clears the login attempt info that is
// saved on the browser during passwordless login. This is needed so that
// future login attempts are not affected by the current one.
await supertokensPasswordless.clearLoginAttemptInfo();
}
await supertokensSession.signOut();
// redirect the user to the first factor login screen
}