Skip to content
Esc
↑↓navigate↵open⌘Jpreview
Dashboard
On this page

Setting up the second factor

Configure SMS OTP as a second authentication factor using the Passwordless recipe.

1. Initialisation

We use 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 ThirdParty from "supertokens-node/recipe/thirdparty";
import EmailPassword from "supertokens-node/recipe/emailpassword";
import Passwordless from "supertokens-node/recipe/passwordless";

supertokens.init({
  framework: "express",
  supertokens: {
    connectionURI: "<CORE_API_ENDPOINT>",
    apiKey: "<YOUR_API_KEY>",
  },
  appInfo: {
    // learn more about this on https://supertokens.com/docs/references/backend-sdks/reference#sdk-configuration
    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",
    }),
    ThirdParty.init({
      //...
    }),
    EmailPassword.init({
      //...
    }),
    Session.init({
      /*Override from previous step*/
    }),
  ],
});
import supertokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
import ThirdParty from "supertokens-node/recipe/thirdparty";
import EmailPassword from "supertokens-node/recipe/emailpassword";
import Passwordless from "supertokens-node/recipe/passwordless";

supertokens.init({
  framework: "hapi",
  supertokens: {
    connectionURI: "<CORE_API_ENDPOINT>",
    apiKey: "<YOUR_API_KEY>",
  },
  appInfo: {
    // learn more about this on https://supertokens.com/docs/references/backend-sdks/reference#sdk-configuration
    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",
    }),
    ThirdParty.init({
      //...
    }),
    EmailPassword.init({
      //...
    }),
    Session.init({
      /*Override from previous step*/
    }),
  ],
});
import supertokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
import ThirdParty from "supertokens-node/recipe/thirdparty";
import EmailPassword from "supertokens-node/recipe/emailpassword";
import Passwordless from "supertokens-node/recipe/passwordless";

supertokens.init({
  framework: "fastify",
  supertokens: {
    connectionURI: "<CORE_API_ENDPOINT>",
    apiKey: "<YOUR_API_KEY>",
  },
  appInfo: {
    // learn more about this on https://supertokens.com/docs/references/backend-sdks/reference#sdk-configuration
    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",
    }),
    ThirdParty.init({
      //...
    }),
    EmailPassword.init({
      //...
    }),
    Session.init({
      /*Override from previous step*/
    }),
  ],
});
import supertokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
import ThirdParty from "supertokens-node/recipe/thirdparty";
import EmailPassword from "supertokens-node/recipe/emailpassword";
import Passwordless from "supertokens-node/recipe/passwordless";

supertokens.init({
  framework: "koa",
  supertokens: {
    connectionURI: "<CORE_API_ENDPOINT>",
    apiKey: "<YOUR_API_KEY>",
  },
  appInfo: {
    // learn more about this on https://supertokens.com/docs/references/backend-sdks/reference#sdk-configuration
    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",
    }),
    ThirdParty.init({
      //...
    }),
    EmailPassword.init({
      //...
    }),
    Session.init({
      /*Override from previous step*/
    }),
  ],
});
import supertokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
import ThirdParty from "supertokens-node/recipe/thirdparty";
import EmailPassword from "supertokens-node/recipe/emailpassword";
import Passwordless from "supertokens-node/recipe/passwordless";

supertokens.init({
  framework: "loopback",
  supertokens: {
    connectionURI: "<CORE_API_ENDPOINT>",
    apiKey: "<YOUR_API_KEY>",
  },
  appInfo: {
    // learn more about this on https://supertokens.com/docs/references/backend-sdks/reference#sdk-configuration
    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",
    }),
    ThirdParty.init({
      //...
    }),
    EmailPassword.init({
      //...
    }),
    Session.init({
      /*Override from previous step*/
    }),
  ],
});
import (
	"github.com/supertokens/supertokens-golang/recipe/emailpassword"
	"github.com/supertokens/supertokens-golang/recipe/emailpassword/epmodels"
	"github.com/supertokens/supertokens-golang/recipe/passwordless"
	"github.com/supertokens/supertokens-golang/recipe/passwordless/plessmodels"
	"github.com/supertokens/supertokens-golang/recipe/session"
	"github.com/supertokens/supertokens-golang/recipe/session/sessmodels"
	"github.com/supertokens/supertokens-golang/recipe/thirdparty"
	"github.com/supertokens/supertokens-golang/recipe/thirdparty/tpmodels"
	"github.com/supertokens/supertokens-golang/supertokens"
)

func main() {
    apiBasePath := "/auth"
    websiteBasePath := "/auth"
	err := supertokens.Init(supertokens.TypeInput{
		Supertokens: &supertokens.ConnectionInfo{
            ConnectionURI: "<CORE_API_ENDPOINT>",
            APIKey: "<YOUR_API_KEY>",
		},
		AppInfo: supertokens.AppInfo{
            AppName: "<YOUR_APP_NAME>",
            APIDomain: "<YOUR_API_DOMAIN>",
            WebsiteDomain: "<YOUR_WEBSITE_DOMAIN>",
            APIBasePath: &apiBasePath,
            WebsiteBasePath: &websiteBasePath,
		},
		RecipeList: []supertokens.Recipe{
            passwordless.Init(plessmodels.TypeInput{
                FlowType: "USER_INPUT_CODE",
                ContactMethodPhone: plessmodels.ContactMethodPhoneConfig{
                    Enabled: true,
                },
            }),
			thirdparty.Init(&tpmodels.TypeInput{ /*...*/ }),
			emailpassword.Init(&epmodels.TypeInput{ /*...*/ }),
			session.Init(&sessmodels.TypeInput{
				/*Override from previous step*/
			}),
		},
	})

	if err != nil {
		panic(err.Error())
	}
}
from supertokens_python import init, InputAppInfo, SupertokensConfig
from supertokens_python.recipe import thirdparty, emailpassword, session, passwordless
from supertokens_python.recipe.passwordless import ContactPhoneOnlyConfig

init(
    app_info=InputAppInfo(
        app_name="<YOUR_APP_NAME>",
        api_domain="<YOUR_API_DOMAIN>",
        website_domain="<YOUR_WEBSITE_DOMAIN>",
        api_base_path="/auth",
        website_base_path="/auth"
    ),
    supertokens_config=SupertokensConfig(
        connection_uri="<CORE_API_ENDPOINT>",
        api_key="<YOUR_API_KEY>"
    ),
    framework='fastapi',
    recipe_list=[
	    session.init(), # contains the override from the previous step
        thirdparty.init(
           # ...
        ),
        emailpassword.init(
            # ...
        ),
        passwordless.init(
            flow_type="USER_INPUT_CODE",
            contact_config=ContactPhoneOnlyConfig()
        )
    ],
    mode='asgi' # use wsgi if you are running using gunicorn
)
from supertokens_python import init, InputAppInfo, SupertokensConfig
from supertokens_python.recipe import thirdparty, emailpassword, session, passwordless
from supertokens_python.recipe.passwordless import ContactPhoneOnlyConfig

init(
    app_info=InputAppInfo(
        app_name="<YOUR_APP_NAME>",
        api_domain="<YOUR_API_DOMAIN>",
        website_domain="<YOUR_WEBSITE_DOMAIN>",
        api_base_path="/auth",
        website_base_path="/auth"
    ),
    supertokens_config=SupertokensConfig(
        connection_uri="<CORE_API_ENDPOINT>",
        api_key="<YOUR_API_KEY>"
    ),
    framework='flask',
    recipe_list=[
	    session.init(), # contains the override from the previous step
        thirdparty.init(
           # ...
        ),
        emailpassword.init(
            # ...
        ),
        passwordless.init(
            flow_type="USER_INPUT_CODE",
            contact_config=ContactPhoneOnlyConfig()
        )
    ]
)
from supertokens_python import init, InputAppInfo, SupertokensConfig
from supertokens_python.recipe import thirdparty, emailpassword, session, passwordless
from supertokens_python.recipe.passwordless import ContactPhoneOnlyConfig

init(
    app_info=InputAppInfo(
        app_name="<YOUR_APP_NAME>",
        api_domain="<YOUR_API_DOMAIN>",
        website_domain="<YOUR_WEBSITE_DOMAIN>",
        api_base_path="/auth",
        website_base_path="/auth"
    ),
    supertokens_config=SupertokensConfig(
        connection_uri="<CORE_API_ENDPOINT>",
        api_key="<YOUR_API_KEY>"
    ),
    framework='django',
    recipe_list=[
	    session.init(), # contains the override from the previous step
        thirdparty.init(
           # ...
        ),
        emailpassword.init(
            # ...
        ),
        passwordless.init(
            flow_type="USER_INPUT_CODE",
            contact_config=ContactPhoneOnlyConfig()
        )
    ],
    mode='asgi' # use wsgi if you are running django server in sync mode
)

The above exposes 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 use the UserMetadata recipe.

The passwordless recipe creates a new userId for the user against which it saves 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;
        },
      };
    },
  },
});
import (
	"github.com/supertokens/supertokens-golang/recipe/passwordless"
	"github.com/supertokens/supertokens-golang/recipe/passwordless/plessmodels"
	"github.com/supertokens/supertokens-golang/recipe/session"
	"github.com/supertokens/supertokens-golang/recipe/session/claims"
	"github.com/supertokens/supertokens-golang/recipe/session/sessmodels"
	"github.com/supertokens/supertokens-golang/recipe/usermetadata"
	"github.com/supertokens/supertokens-golang/supertokens"
)

func main() {
	passwordless.Init(plessmodels.TypeInput{
		FlowType: "USER_INPUT_CODE",
		ContactMethodPhone: plessmodels.ContactMethodPhoneConfig{
			Enabled: true,
		},
		Override: &plessmodels.OverrideStruct{
			APIs: func(originalImplementation plessmodels.APIInterface) plessmodels.APIInterface {
				// this API is called when the user enters the OTP
				oConsumeCodePOST := *originalImplementation.ConsumeCodePOST
				nConsumeCodePost := func(userInput *plessmodels.UserInputCodeWithDeviceID, linkCode *string, preAuthSessionID string, tenantId string, options plessmodels.APIOptions, userContext supertokens.UserContext) (plessmodels.ConsumeCodePOSTResponse, error) {
					// - 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
					session, err := session.GetSession(options.Req, options.Res, &sessmodels.VerifySessionOptions{
						OverrideGlobalClaimValidators: func(globalClaimValidators []claims.SessionClaimValidator, sessionContainer sessmodels.SessionContainer, userContext supertokens.UserContext) ([]claims.SessionClaimValidator, error) {
							return []claims.SessionClaimValidator{}, nil
						},
					})
					if err != nil {
						return plessmodels.ConsumeCodePOSTResponse{}, err
					}

					resp, err := oConsumeCodePOST(userInput, linkCode, preAuthSessionID, tenantId, options, userContext)
					if err != nil {
						return resp, err
					}

					if resp.OK != nil {
						// 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.
						usermetadata.UpdateUserMetadata(
							session.GetUserID(),
							map[string]interface{}{
								"passwordlessUserId": resp.OK.User.ID,
							},
						)
					}
					return resp, err
				}
				*originalImplementation.ConsumeCodePOST = nConsumeCodePost
				return originalImplementation
			},
		},
	})
}
from supertokens_python.recipe.passwordless.interfaces import APIInterface, APIOptions, ConsumeCodePostOkResult
from typing import Union, Dict, Any, Optional
from supertokens_python.recipe.session.asyncio import get_session
from supertokens_python.recipe.usermetadata.asyncio import update_user_metadata
from supertokens_python.recipe import passwordless
from supertokens_python.recipe.session.interfaces import SessionContainer

def override_passwordless_apis(original_implementation: APIInterface):
    original_consume_code_post = original_implementation.consume_code_post

    async def consume_code_post(
        pre_auth_session_id: str,
        user_input_code: Union[str, None],
        device_id: Union[str, None],
        link_code: Union[str, None],
        session: Optional[SessionContainer],
        should_try_linking_with_session_user: Union[bool, None],
        tenant_id: str,
        api_options: APIOptions,
        user_context: Dict[str, Any],
    ):
        # this API is called when the user enters the OTP

        # we should already have a session here since this is called
        # after first factor login
        _session = await get_session(api_options.request)
        assert _session is not None

        res = await original_consume_code_post(
            pre_auth_session_id,
            user_input_code,
            device_id,
            link_code,
            session,
            should_try_linking_with_session_user,
            tenant_id,
            api_options,
            user_context,
        )

        if isinstance(res, ConsumeCodePostOkResult):
            # 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 update_user_metadata(
                _session.get_user_id(),  # this is the userId of the first factor login
                {"passwordlessUserId": res.user.id}
            )

        return res

    original_implementation.consume_code_post = consume_code_post
    return original_implementation

passwordless.init(
    flow_type="USER_INPUT_CODE",
    contact_config=passwordless.ContactPhoneOnlyConfig(),
    override=passwordless.InputOverrideConfig(
        apis=override_passwordless_apis
    ),
)

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 creates a new session on successful 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 is 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.recipeUserId,
                input.tenantId,
                undefined,
                input.userContext,
              )),
            },
          });
        },
      };
    },
  },
});
import (
	"github.com/supertokens/supertokens-golang/recipe/passwordless"
	"github.com/supertokens/supertokens-golang/recipe/passwordless/plessmodels"
	"github.com/supertokens/supertokens-golang/recipe/session"
	"github.com/supertokens/supertokens-golang/recipe/session/claims"
	"github.com/supertokens/supertokens-golang/recipe/session/sessmodels"
	"github.com/supertokens/supertokens-golang/recipe/usermetadata"
	"github.com/supertokens/supertokens-golang/supertokens"
)

func main() {
	SecondFactorClaim, _ := claims.BooleanClaim("2fa-completed", func(userId, tenantId string, userContext supertokens.UserContext) (interface{}, error) {
		return false, nil
	}, nil)

	passwordless.Init(plessmodels.TypeInput{
		FlowType: "USER_INPUT_CODE",
		ContactMethodPhone: plessmodels.ContactMethodPhoneConfig{
			Enabled: true,
		},
		Override: &plessmodels.OverrideStruct{
			APIs: func(originalImplementation plessmodels.APIInterface) plessmodels.APIInterface {
				// this API is called when the user enters the OTP
				oConsumeCodePOST := *originalImplementation.ConsumeCodePOST
				nConsumeCodePost := func(userInput *plessmodels.UserInputCodeWithDeviceID, linkCode *string, preAuthSessionID string, tenantId string, options plessmodels.APIOptions, userContext supertokens.UserContext) (plessmodels.ConsumeCodePOSTResponse, error) {
					// - 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
					session, err := session.GetSession(options.Req, options.Res, &sessmodels.VerifySessionOptions{
						OverrideGlobalClaimValidators: func(globalClaimValidators []claims.SessionClaimValidator, sessionContainer sessmodels.SessionContainer, userContext supertokens.UserContext) ([]claims.SessionClaimValidator, error) {
							return []claims.SessionClaimValidator{}, nil
						},
					})
					if err != nil {
						return plessmodels.ConsumeCodePOSTResponse{}, err
					}

					// we add the existing session to the user context so that the createNewSession
					// function doesn't create a new session
					(*userContext)["session"] = session

					resp, err := oConsumeCodePOST(userInput, linkCode, preAuthSessionID, tenantId, options, userContext)
					if err != nil {
						return resp, err
					}

					if resp.OK != nil {
						// OTP verification was successful. We can now mark the
						// session's payload as is2faComplete: true so that
						// the user has access to API routes and the frontend UI
						resp.OK.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.
						usermetadata.UpdateUserMetadata(
							session.GetUserID(),
							map[string]interface{}{
								"passwordlessUserId": resp.OK.User.ID,
							},
						)
					}
					return resp, err
				}
				*originalImplementation.ConsumeCodePOST = nConsumeCodePost
				return originalImplementation
			},
		},
	})

	session.Init(&sessmodels.TypeInput{
		Override: &sessmodels.OverrideStruct{
			Functions: func(originalImplementation sessmodels.RecipeInterface) sessmodels.RecipeInterface {
				oCreateNewSession := *originalImplementation.CreateNewSession
				/* This function is called after signing in or signing up via the first factor */
				(*originalImplementation.CreateNewSession) = func(userID string, accessTokenPayload, sessionDataInDatabase map[string]interface{}, disableAntiCsrf *bool, tenantId string, userContext supertokens.UserContext) (sessmodels.SessionContainer, error) {
					if session, ok := (*userContext)["session"].(sessmodels.SessionContainer); ok {
						/**
						 * This will be true for the second factor login.
						 * So instead of creating a new session, we return the already existing one.
						 */
						return session, nil
					}

					if accessTokenPayload == nil {
						accessTokenPayload = map[string]interface{}{}
					}
					accessTokenPayload, err := SecondFactorClaim.Build(userID, tenantId, accessTokenPayload, userContext)
					if err != nil {
						return nil, err
					}
					return oCreateNewSession(userID, accessTokenPayload, sessionDataInDatabase, disableAntiCsrf, tenantId, userContext)
				}
				return originalImplementation
			},
		},
	})
}
from supertokens_python.recipe.passwordless.interfaces import (
    APIInterface,
    APIOptions,
    ConsumeCodePostOkResult,
)
from typing import Union, Dict, Any, Optional
from supertokens_python.recipe.session.asyncio import get_session
from supertokens_python.recipe.usermetadata.asyncio import update_user_metadata
from supertokens_python.recipe.session.interfaces import (
    SessionContainer,
    RecipeInterface,
)
from supertokens_python.recipe.session.claims import BooleanClaim
from supertokens_python.types import RecipeUserId

SecondFactorClaim = BooleanClaim(
    key="2fa-completed", fetch_value=lambda _, __, ___, ____, _____: False
)


def override_passwordless_apis(original_implementation: APIInterface):
    original_consume_code_post = original_implementation.consume_code_post

    async def consume_code_post(
        pre_auth_session_id: str,
        user_input_code: Union[str, None],
        device_id: Union[str, None],
        link_code: Union[str, None],
        session: Optional[SessionContainer],
        should_try_linking_with_session_user: Union[bool, None],
        tenant_id: str,
        api_options: APIOptions,
        user_context: Dict[str, Any],
    ):
        # this API is called when the user enters the OTP

        # 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
        _session = await get_session(
            api_options.request, override_global_claim_validators=lambda _, __, ___: []
        )
        assert _session is not None

        # we should add the existing session to the user_context
        # so that the create_new_session function
        # doesn't create a new session
        user_context["session"] = _session

        res = await original_consume_code_post(
            pre_auth_session_id,
            user_input_code,
            device_id,
            link_code,
            session,
            should_try_linking_with_session_user,
            tenant_id,
            api_options,
            user_context,
        )

        if isinstance(res, ConsumeCodePostOkResult):
            # OTP verification was successful. We can now mark the
            # session's payload as {"is2faComplete": True} so that
            # the user has access to API routes and the frontend UI
            await _session.set_claim_value(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 update_user_metadata(
                _session.get_user_id(),  # userId of the first factor login
                {"passwordlessUserId": res.user.id},
            )

        return res

    original_implementation.consume_code_post = consume_code_post
    return original_implementation


def override_session_functions(original_implementation: RecipeInterface):
    original_create_new_session = original_implementation.create_new_session

    async def create_new_session(
        user_id: str,
        recipe_user_id: RecipeUserId,
        access_token_payload: Optional[Dict[str, Any]],
        session_data_in_database: Optional[Dict[str, Any]],
        disable_anti_csrf: Optional[bool],
        tenant_id: str,
        user_context: Dict[str, Any],
    ):
        # This function is called after signing in or
        # signing up via the first factor

        _session = user_context.get("session")
        if _session and isinstance(_session, SessionContainer):
            # This is true for the second factor login.
            # So instead of creating a new session, we return the already existing one.
            return _session

        if access_token_payload is None:
            access_token_payload = {}

        access_token_payload = {
            **access_token_payload,
            **(
                await SecondFactorClaim.build(
                    user_id,
                    recipe_user_id,
                    tenant_id,
                    access_token_payload,
                    user_context,
                )
            ),
        }
        return await original_create_new_session(
            user_id,
            recipe_user_id,
            access_token_payload,
            session_data_in_database,
            disable_anti_csrf,
            tenant_id,
            user_context,
        )

    original_implementation.create_new_session = create_new_session
    return original_implementation

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 their phone number for the second factor auth.
  • OTP is sent to Mallory’s phone number and they 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";
import SuperTokens from "supertokens-node";

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 is
          // defined if this is a sign in attempt, in which case, we 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 comes here during a login attempt, since we
            // associate the passwordless userId to the user on sign up
            let passwordlessUserInfo = await SuperTokens.getUser(
              userMetadata.metadata.passwordlessUserId as string,
              input.userContext,
            );
            phoneNumber = passwordlessUserInfo?.phoneNumbers[0];
          }

          if (phoneNumber !== undefined) {
            // this means we found a phone number associated to this user.
            // we 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;
        },
      };
    },
  },
});
import (
	"errors"

	"github.com/supertokens/supertokens-golang/recipe/passwordless"
	"github.com/supertokens/supertokens-golang/recipe/passwordless/plessmodels"
	"github.com/supertokens/supertokens-golang/recipe/session"
	"github.com/supertokens/supertokens-golang/recipe/session/claims"
	"github.com/supertokens/supertokens-golang/recipe/session/sessmodels"
	"github.com/supertokens/supertokens-golang/recipe/usermetadata"
	"github.com/supertokens/supertokens-golang/supertokens"
)

func main() {
	passwordless.Init(plessmodels.TypeInput{
		FlowType: "USER_INPUT_CODE",
		ContactMethodPhone: plessmodels.ContactMethodPhoneConfig{
			Enabled: true,
		},
		Override: &plessmodels.OverrideStruct{
			APIs: func(originalImplementation plessmodels.APIInterface) plessmodels.APIInterface {
				/*This API is called to send an OTP*/
				oCreateCodePOST := *originalImplementation.CreateCodePOST
				nCreateCodePOST := func(email *string, phoneNumber *string, tenantId string, options plessmodels.APIOptions, userContext supertokens.UserContext) (plessmodels.CreateCodePOSTResponse, error) {
					/**
					 * 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
					session, err := session.GetSession(options.Req, options.Res, &sessmodels.VerifySessionOptions{
						OverrideGlobalClaimValidators: func(globalClaimValidators []claims.SessionClaimValidator, sessionContainer sessmodels.SessionContainer, userContext supertokens.UserContext) ([]claims.SessionClaimValidator, error) {
							return []claims.SessionClaimValidator{}, nil
						},
					})
					if err != nil {
						return plessmodels.CreateCodePOSTResponse{}, err
					}

					// We try and get the phone number associated with this user. It is
					// defined if this is a sign in attempt, in which case, we check that
					// it is equal to the input phone number
					userMetadata, err := usermetadata.GetUserMetadata(session.GetUserID(), userContext)
					if err != nil {
						return plessmodels.CreateCodePOSTResponse{}, err
					}
					var userPhoneNumber *string
					if passwordlessUserId, ok := userMetadata["passwordlessUserId"].(string); ok {
						// the flow comes here during a login attempt, since we
						// associate the passwordless userId to the user on sign up
						passwordlessUserInfo, err := passwordless.GetUserByID(passwordlessUserId, userContext)
						if err != nil {
							return plessmodels.CreateCodePOSTResponse{}, err
						}
						userPhoneNumber = passwordlessUserInfo.PhoneNumber
					}
					if userPhoneNumber != nil {
						// 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 == nil || *phoneNumber != *userPhoneNumber {
							return plessmodels.CreateCodePOSTResponse{}, errors.New("Input phone number is not the same as the one saved for this user")
						}
					}

					return oCreateCodePOST(email, phoneNumber, tenantId, options, userContext)
				}
				*originalImplementation.CreateCodePOST = nCreateCodePOST

				oConsumeCodePOST := *originalImplementation.ConsumeCodePOST
				nConsumeCodePost := func(userInput *plessmodels.UserInputCodeWithDeviceID, linkCode *string, preAuthSessionID string, tenantId string, options plessmodels.APIOptions, userContext supertokens.UserContext) (plessmodels.ConsumeCodePOSTResponse, error) {
					/*...mofications from previous step */
					resp, err := oConsumeCodePOST(userInput, linkCode, preAuthSessionID, tenantId, options, userContext)
					/*...mofications from previous step */
					return resp, err
				}
				*originalImplementation.ConsumeCodePOST = nConsumeCodePost

				return originalImplementation
			},
		},
	})
}
from supertokens_python.recipe.passwordless.interfaces import APIInterface, APIOptions
from typing import Union, Dict, Any, Optional
from supertokens_python.recipe.session.asyncio import get_session
from supertokens_python.recipe.usermetadata.asyncio import get_user_metadata
from supertokens_python.recipe.session.interfaces import SessionContainer
from supertokens_python.asyncio import get_user


def override_passwordless_apis(original_implementation: APIInterface):
    original_consume_code_post = original_implementation.consume_code_post
    original_create_code_post = original_implementation.create_code_post

    async def create_code_post(
        email: Union[str, None],
        phone_number: Union[str, None],
        session: Optional[SessionContainer],
        should_try_linking_with_session_user: Union[bool, None],
        tenant_id: str,
        api_options: APIOptions,
        user_context: Dict[str, Any],
    ):
        # This API is called to send an OTP

        # 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
        _session = await get_session(
            api_options.request, override_global_claim_validators=lambda _, __, ___: []
        )
        assert _session is not None

        # We try to get the phone number associated with this user. It is
        # defined if this is a sign in attempt, in which case, we check that
        # it is equal to the input phone number
        user_metadata = await get_user_metadata(_session.get_user_id())
        user_metadata_phone_number: Optional[str] = None
        if user_metadata.metadata.get("passwordlessUserId"):
            # the flow comes here during a login attempt, since we
            # associate the passwordless userId to the user on sign up
            passwordless_user_info = await get_user(
                user_metadata.metadata["passwordlessUserId"], user_context
            )
            if passwordless_user_info is not None:
                user_metadata_phone_number = passwordless_user_info.phone_numbers[0]

        if user_metadata_phone_number is not None:
            # 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 (phone_number is None) or (phone_number != user_metadata_phone_number):
                raise Exception(
                    "Input phone number is not the same as the one saved for this user"
                )

        return await original_create_code_post(
            email,
            phone_number,
            session,
            should_try_linking_with_session_user,
            tenant_id,
            api_options,
            user_context,
        )


    async def consume_code_post(
        pre_auth_session_id: str,
        user_input_code: Union[str, None],
        device_id: Union[str, None],
        link_code: Union[str, None],
        session: Optional[SessionContainer],
        should_try_linking_with_session_user: Union[bool, None],
        tenant_id: str,
        api_options: APIOptions,
        user_context: Dict[str, Any],
    ):
        # ...Modifications from previous step
        res = await original_consume_code_post(
            pre_auth_session_id,
            user_input_code,
            device_id,
            link_code,
            session,
            should_try_linking_with_session_user,
            tenant_id,
            api_options,
            user_context,
        )
        # ...Modifications from previous step
        return res

    original_implementation.create_code_post = create_code_post
    original_implementation.consume_code_post = consume_code_post
    return original_implementation

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 initiate 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 SuperTokens from "supertokens-node";

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 is 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 SuperTokens.getUser(
              userMetadata.metadata.passwordlessUserId as string,
              input.userContext,
            );
            phoneNumber = passwordlessUserInfo?.phoneNumbers[0];
          }

          return originalImplementation.createNewSession({
            ...input,
            accessTokenPayload: {
              ...input.accessTokenPayload,
              ...(await SecondFactorClaim.build(
                input.userId,
                input.recipeUserId,
                input.tenantId,
                undefined,
                input.userContext,
              )),
              phoneNumber,
            },
          });
        },
      };
    },
  },
});
import (
	"github.com/supertokens/supertokens-golang/recipe/passwordless"
	"github.com/supertokens/supertokens-golang/recipe/session"
	"github.com/supertokens/supertokens-golang/recipe/session/claims"
	"github.com/supertokens/supertokens-golang/recipe/session/sessmodels"
	"github.com/supertokens/supertokens-golang/recipe/usermetadata"
	"github.com/supertokens/supertokens-golang/supertokens"
)

func main() {
	SecondFactorClaim, _ := claims.BooleanClaim("2fa-completed", func(userId, tenantId string, userContext supertokens.UserContext) (interface{}, error) {
		return false, nil
	}, nil)

	session.Init(&sessmodels.TypeInput{
		Override: &sessmodels.OverrideStruct{
			Functions: func(originalImplementation sessmodels.RecipeInterface) sessmodels.RecipeInterface {
				oCreateNewSession := *originalImplementation.CreateNewSession
				/* This function is called after signing in or signing up via the first factor */
				(*originalImplementation.CreateNewSession) = func(userID string, accessTokenPayload, sessionDataInDatabase map[string]interface{}, disableAntiCsrf *bool, tenantId string, userContext supertokens.UserContext) (sessmodels.SessionContainer, error) {
					if session, ok := (*userContext)["session"].(sessmodels.SessionContainer); ok {
						/**
						 * This will be true for the second factor login.
						 * So instead of creating a new session, we return the already existing one.
						 */
						return session, nil
					}

					// we first get the passwordless userId associated with this user
					// using the UserMetadata recipe
					userMetadata, err := usermetadata.GetUserMetadata(userID, userContext)
					if err != nil {
						return nil, err
					}
					var userPhoneNumber *string
					if passwordlessUserId, ok := userMetadata["passwordlessUserId"].(string); ok {
						passwordlessUserInfo, err := passwordless.GetUserByID(passwordlessUserId, userContext)
						if err != nil {
							return nil, err
						}
						userPhoneNumber = passwordlessUserInfo.PhoneNumber
					}

					if accessTokenPayload == nil {
						accessTokenPayload = map[string]interface{}{}
					}
					accessTokenPayload, err = SecondFactorClaim.Build(userID, tenantId, accessTokenPayload, userContext)
					if err != nil {
						return nil, err
					}
					if userPhoneNumber != nil {
						accessTokenPayload["phoneNumber"] = *userPhoneNumber
					}
					return oCreateNewSession(userID, accessTokenPayload, sessionDataInDatabase, disableAntiCsrf, tenantId, userContext)
				}
				return originalImplementation
			},
		},
	})
}
from typing import Dict, Any, Optional
from supertokens_python.recipe.usermetadata.asyncio import get_user_metadata
from supertokens_python.asyncio import get_user
from supertokens_python.recipe.session.interfaces import (
    SessionContainer,
    RecipeInterface,
)
from supertokens_python.recipe.session.claims import BooleanClaim
from supertokens_python.types import RecipeUserId

SecondFactorClaim = BooleanClaim(
    key="2fa-completed", fetch_value=lambda _, __, ___, ____, _____: False
)


def override_session_functions(original_implementation: RecipeInterface):
    original_create_new_session = original_implementation.create_new_session

    async def create_new_session(
        user_id: str,
        recipe_user_id: RecipeUserId,
        access_token_payload: Optional[Dict[str, Any]],
        session_data_in_database: Optional[Dict[str, Any]],
        disable_anti_csrf: Optional[bool],
        tenant_id: str,
        user_context: Dict[str, Any],
    ):
        # This function is called after signing in
        # or signing up via the first factor

        _session = user_context.get("session")
        if _session and isinstance(_session, SessionContainer):
            # This is true for the second factor login.
            # So instead of creating a new session, we return the already existing one.
            return _session

        if access_token_payload is None:
            access_token_payload = {}

        # we first get the passwordless user id associated with this user
        # using the user_metadata recipe
        user_metadata = await get_user_metadata(user_id)
        phone_number: Optional[str] = None
        if user_metadata.metadata.get("passwordlessUserId") is not None:
            # We get the phone number associated with the passwordless userId
            passwordless_user_info = await get_user(
                user_metadata.metadata["passwordlessUserId"], user_context
            )
            if passwordless_user_info is not None:
                phone_number = passwordless_user_info.phone_numbers[0]

        # Insert "is2faComplete" and "phoneNumber" in the access token payload
        access_token_payload = {
            **access_token_payload,
            **(
                await SecondFactorClaim.build(
                    user_id,
                    recipe_user_id,
                    tenant_id,
                    access_token_payload,
                    user_context,
                )
            ),
            "phoneNumber": phone_number,
        }
        return await original_create_new_session(
            user_id,
            recipe_user_id,
            access_token_payload,
            session_data_in_database,
            disable_anti_csrf,
            tenant_id,
            user_context,
        )

    original_implementation.create_new_session = create_new_session
    return original_implementation

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 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;
        },
      };
    },
  },
});
import (
	"errors"

	"github.com/supertokens/supertokens-golang/recipe/passwordless"
	"github.com/supertokens/supertokens-golang/recipe/passwordless/plessmodels"
	"github.com/supertokens/supertokens-golang/recipe/session"
	"github.com/supertokens/supertokens-golang/recipe/session/claims"
	"github.com/supertokens/supertokens-golang/recipe/session/sessmodels"
	"github.com/supertokens/supertokens-golang/supertokens"
)

func main() {
	passwordless.Init(plessmodels.TypeInput{
		FlowType: "USER_INPUT_CODE",
		ContactMethodPhone: plessmodels.ContactMethodPhoneConfig{
			Enabled: true,
		},
		Override: &plessmodels.OverrideStruct{
			APIs: func(originalImplementation plessmodels.APIInterface) plessmodels.APIInterface {
				/*This API is called to send an OTP*/
				oCreateCodePOST := *originalImplementation.CreateCodePOST
				nCreateCodePOST := func(email *string, phoneNumber *string, tenantId string, options plessmodels.APIOptions, userContext supertokens.UserContext) (plessmodels.CreateCodePOSTResponse, error) {
					/**
					 * 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
					session, err := session.GetSession(options.Req, options.Res, &sessmodels.VerifySessionOptions{
						OverrideGlobalClaimValidators: func(globalClaimValidators []claims.SessionClaimValidator, sessionContainer sessmodels.SessionContainer, userContext supertokens.UserContext) ([]claims.SessionClaimValidator, error) {
							return []claims.SessionClaimValidator{}, nil
						},
					})
					if err != nil {
						return plessmodels.CreateCodePOSTResponse{}, err
					}

					var userPhoneNumber *string
					if phoneNumber, ok := session.GetAccessTokenPayloadWithContext(userContext)["phoneNumber"].(string); ok {
						userPhoneNumber = &phoneNumber
					}

					if userPhoneNumber != nil {
						// 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 == nil || *phoneNumber != *userPhoneNumber {
							return plessmodels.CreateCodePOSTResponse{}, errors.New("Input phone number is not the same as the one saved for this user")
						}
					}

					return oCreateCodePOST(email, phoneNumber, tenantId, options, userContext)
				}
				*originalImplementation.CreateCodePOST = nCreateCodePOST

				oConsumeCodePOST := *originalImplementation.ConsumeCodePOST
				nConsumeCodePost := func(userInput *plessmodels.UserInputCodeWithDeviceID, linkCode *string, preAuthSessionID string, tenantId string, options plessmodels.APIOptions, userContext supertokens.UserContext) (plessmodels.ConsumeCodePOSTResponse, error) {
					/*...mofications from previous step */
					resp, err := oConsumeCodePOST(userInput, linkCode, preAuthSessionID, tenantId, options, userContext)
					/*...mofications from previous step */
					return resp, err
				}
				*originalImplementation.ConsumeCodePOST = nConsumeCodePost

				return originalImplementation
			},
		},
	})
}
from supertokens_python.recipe.passwordless.interfaces import APIInterface, APIOptions
from typing import Union, Dict, Any, Optional
from supertokens_python.recipe.session.asyncio import get_session
from supertokens_python.recipe.session.interfaces import SessionContainer


def override_passwordless_apis(original_implementation: APIInterface):
    original_create_code_post = original_implementation.create_code_post

    async def create_code_post(
        email: Union[str, None],
        phone_number: Union[str, None],
        session: Optional[SessionContainer],
        should_try_linking_with_session_user: Union[bool, None],
        tenant_id: str,
        api_options: APIOptions,
        user_context: Dict[str, Any],
    ):
        # This API is called to send an OTP

        # 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
        _session = await get_session(
            api_options.request, override_global_claim_validators=lambda _, __, ___: []
        )
        assert _session is not None

        payload_phone_number = _session.get_access_token_payload().get("phoneNumber")

        if payload_phone_number is not None:
            # 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 (phone_number is None) or (phone_number != payload_phone_number):
                raise Exception(
                    "Input phone number is not the same as the one saved for this user"
                )

        return await original_create_code_post(
            email,
            phone_number,
            session,
            should_try_linking_with_session_user,
            tenant_id,
            api_options,
            user_context,
        )

    original_implementation.create_code_post = create_code_post

API reference

API schema and response details