Skip to main content

3) Passwordless customisation

This recipe will provide APIs on the backend which can be used to implement the second login challenge - verifying the phone number via an OTP.

Start by following the backend quick setup guide for the passwordless recipe, and setting the contactMethod to "PHONE" and setting flowType to "USER_INPUT_CODE".

import Passwordless from "supertokens-node/recipe/passwordless";
import supertokens from "supertokens-node";

supertokens.init({
framework: "...",
appInfo: { /*...*/ },
recipeList: [
Passwordless.init({
contactMethod: "PHONE",
flowType: "USER_INPUT_CODE",
})
]
})

Check the phone number in the code creation API#

The first step to verifying the phone number is to send a SMS code to the phone number. The passwordless recipe provides an API for that: {apiBasePath}/signinup/code POST. We want to modify this API to check that the input phone number is the same as the one that was stored in the session. This will prevent malicious users from verifying a different phone number that the one they entered in the first login challenge:

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

supertokens.init({
framework: "...",
appInfo: { /*...*/ },
recipeList: [
Passwordless.init({
contactMethod: "PHONE",
flowType: "USER_INPUT_CODE",
override: {
apis: (oI) => {
return {
...oI,
createCodePOST: async function (input) {
if (oI.createCodePOST === undefined) {
throw new Error("Should never come here");
}
/**
*
* We want to make sure that the OTP being generated is for the
* same number that was used in the first login challenge. Otherwise
* someone could "hack" the frontend to change the phone number
* being sent for the second login challenge.
*/

/*
We pass overrideGlobalClaimValidators: () => [], so that the PhoneVerifiedClaim
claim check does not happen, cause if it does, getSession will throw
an error since we have not yet verified the user's phone number.
*/
let session = await Session.getSession(input.options.req, input.options.res, {
overrideGlobalClaimValidators: () => [],
});

/*
We had saved the phone number in the session in the previous customisation step.
*/
let phoneNumber: string = session.getAccessTokenPayload().phoneNumber;

if (!("phoneNumber" in input) || input.phoneNumber !== phoneNumber) {
/*This means that the phone number that this API
got was not the same one as entered in the first challenge.

It should come here only if someone is maliciously trying
to break our login flow.*/
throw new Error("Should never come here");
}

return oI.createCodePOST(input);
},
};
},
},
})
]
})

Mark phone number as verified post OTP verification#

The OTP is verified in the {apiBasePath}/signinup/code/consume POST API. Post verification, we want to change the access token payload to set the phone-verified claim to true. This can be done by overriding the consumeCodePOST API:

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

supertokens.init({
framework: "...",
appInfo: { /*...*/ },
recipeList: [
Passwordless.init({
contactMethod: "PHONE",
flowType: "USER_INPUT_CODE",
override: {
apis: (oI) => {
return {
...oI,
createCodePOST: async function (input) {
if (oI.createCodePOST === undefined) {
throw new Error("Should never come here");
}

/*...*/
/* Code from previous step... */
/*...*/

return oI.createCodePOST(input);
},
consumeCodePOST: async function (input) {
if (oI.consumeCodePOST === undefined) {
throw new Error("Should never come here");
}
// we should already have a session here since this is called
// after phone password login
let session = await Session.getSession(input.options.req, input.options.res, {
overrideGlobalClaimValidators: () => [],
});
if (session === undefined) {
throw new Error("Should never come here");
}

// we add the 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 mark the
// session's payload as phone-verified: true so that
// the user has access to API routes and the frontend UI
await session.setClaimValue(PhoneVerifiedClaim, true, input.userContext);
}

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

When we call the orginal implementation (await oI.consumeCodePOST(input);), that function also calls the createNewSession function from the session recipe. However, we already have a session from the first login challenge, so we must somehow communicate to the createNewSession function to not create a new session in case a session already exists.

We do this by saving the current session in the input.userContext object:

input.userContext.session = session;

Then in the createNewSession function, we check if input.userContext.session exists, and if it does, we do not create a new session, and instead just return the existing one:

import EmailPassword from "supertokens-node/recipe/emailpassword";
import Passwordless from "supertokens-node/recipe/passwordless";
import Session from "supertokens-node/recipe/session";
import supertokens from "supertokens-node";

supertokens.init({
framework: "...",
appInfo: { /*...*/ },
recipeList: [
EmailPassword.init({ /* ... */}),
Passwordless.init({ /* ... */ }),
Session.init({
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
createNewSession: async function (input) {
if (input.userContext.session !== undefined) {
// if it comes here, it means that we already have an
// existing session
return input.userContext.session;
} else {
// we also get the phone number of the user and save it in the
// session so that the OTP can be sent to it directly
let userInfo = await EmailPassword.getUserById(input.userId, input.userContext);
return originalImplementation.createNewSession({
...input,
accessTokenPayload: {
...input.accessTokenPayload,
...PhoneVerifiedClaim.build(input.userId, input.userContext),
phoneNumber: userInfo?.email,
},
});
}
},
};
},
},
})
]
})
Which frontend SDK do you use?
supertokens-web-js / mobile
supertokens-auth-react