Skip to content
Esc
navigateopen⌘Jpreview
Dashboard
On this page

Add passwords to an existing account

Add a new password to an existing account using the account linking feature.

Overview

There may be scenarios in which you want to add a password to an account created using a social provider or passwordless login. This guide walks you through how to do this.

The idea here is to reuse the existing sign up APIs, but call them with a session’s access token. The APIs then create a new recipe user for that login method based on the input, and then link that to the session user. Of course, there are security checks done to ensure there is no account takeover risk, and this guide goes through them as well.

Before you start

Enable paid features

This feature is only available to paid users. Follow the instructions below to enable it.

Managed Service

  1. Sign in to the SuperTokens dashboard.
  2. Select the managed service option from the service type select component.
  3. Select your core instance from the next elemenet or create a new one.
  4. Open Features sub-page and enable the required ones.

Self Hosted

  1. Sign in to the SuperTokens dashboard.
  2. Select the self-hosted option from the service type select component.
  3. Select your license key from the next elemenet or create a new one. Then enable the required features.
  4. If the key is not yet configured, add it to your Core service. If your Core already uses this key, no configuration changes are required.

We do not provide pre-built UI for this flow since it’s probably something you want to add in your settings page or during the sign up process. This guide focuses on which APIs to call from your own UI.

The frontend code snippets below refer to the supertokens-web-js SDK. You can continue to use this even if you have initialised the supertokens-auth-react SDK, on the frontend.

Steps

1. Enable account linking and emailpassword on the backend SDK

import supertokens, { User, RecipeUserId } from "supertokens-node";
import AccountLinking from "supertokens-node/recipe/accountlinking";
import { AccountInfoWithRecipeId } from "supertokens-node/recipe/accountlinking/types";
import { SessionContainerInterface } from "supertokens-node/recipe/session/types";
import EmailPassword from "supertokens-node/recipe/emailpassword";

supertokens.init({
  supertokens: {
    connectionURI: "...",
    apiKey: "...",
  },
  appInfo: {
    apiDomain: "...",
    appName: "...",
    websiteDomain: "...",
  },
  recipeList: [
    EmailPassword.init(),
    AccountLinking.init({
      shouldDoAutomaticAccountLinking: async (
        newAccountInfo: AccountInfoWithRecipeId & { recipeUserId?: RecipeUserId },
        user: User | undefined,
        session: SessionContainerInterface | undefined,
        tenantId: string,
        userContext: any,
      ) => {
        if (user === undefined) {
          return {
            shouldAutomaticallyLink: true,
            shouldRequireVerification: true,
          };
        }
        if (session !== undefined && session.getUserId() === user.id && session.getTenantId() === tenantId) {
          return {
            shouldAutomaticallyLink: true,
            shouldRequireVerification: true,
          };
        }
        return {
          shouldAutomaticallyLink: false,
        };
      },
    }),
  ],
});
from typing import Any, Dict, Optional, Union

from supertokens_python.recipe import accountlinking, emailpassword
from supertokens_python.recipe.accountlinking.types import (
    AccountInfoWithRecipeIdAndUserId,
    ShouldAutomaticallyLink,
    ShouldNotAutomaticallyLink,
)
from supertokens_python.recipe.session.interfaces import SessionContainer
from supertokens_python.types import User


async def should_do_automatic_account_linking(
    new_account_info: AccountInfoWithRecipeIdAndUserId,
    user: Optional[User],
    session: Optional[SessionContainer],
    tenant_id: str,
    user_context: Dict[str, Any],
) -> Union[ShouldNotAutomaticallyLink, ShouldAutomaticallyLink]:
    if user is None:
        return ShouldAutomaticallyLink(should_require_verification=True)

    if (
        session is not None
        and session.get_user_id() == user.id
        and session.get_tenant_id() == tenant_id
    ):
        return ShouldAutomaticallyLink(should_require_verification=True)

    return ShouldNotAutomaticallyLink()


recipe_list = [
    emailpassword.init(),
    accountlinking.init(
        should_do_automatic_account_linking=should_do_automatic_account_linking
    ),
]

The callback allows a new user to become a primary user when user is absent. It links to an existing user only when the session user and tenant match the proposed primary user and current tenant. It therefore does not enable linking between existing users during first-factor authentication. To enable that behavior, see the automatic account linking documentation.

2. Create a UI to show a password input to the user and handle the submit event

First, you need to detect if there already exists a password for the user. You can do this by inspecting the user object on the backend and checking if there is an emailpassword login method.

Then, if no such login method exists, you have to show a UI in which the user can add a password to their account. The password validation documentation contains the default password validation rules.

You also need to fetch a verified email for the current tenant before you call the email-password sign-up API. Fetch it on the backend from a login method on the user object whose tenantIds contains the session tenant. Do not accept an email from the client as proof of ownership. If no tenant-scoped, verified email exists, first complete an email OTP flow through the passwordless recipe and link that login method to the same session user.

Once you have the email on the frontend, you should call the sign up API. The two big differences in the implementation are:

  • When you call the sign up API, you need to provide the session’s access token in the request. If you are using the frontend SDK, this process happens automatically via the frontend network interceptors. The access token enables the backend to get a session and then link the email password account to session user.
  • New types of failure scenarios exist when calling the sign up API which are impossible during first factor login. To learn more about them, see the error codes section (> ERR_CODE_008).

3. Check for email match in the backend sign up API

Since the frontend specifies the email, verify its ownership on the backend before using it. The email must belong to a verified login method for the session user in the request tenant. You can enforce this by overriding the email-password sign-up API:

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

SuperTokens.init({
  appInfo: {
    apiDomain: "...",
    appName: "...",
    websiteDomain: "...",
  },
  supertokens: {
    connectionURI: "...",
  },
  recipeList: [
    EmailPassword.init({
      override: {
        apis: (originalImplementation) => {
          return {
            ...originalImplementation,
            signUpPOST: async function (input) {
              if (input.session !== undefined) {
                // this means that we are trying to add a password to the session user
                const inputEmail = input.formFields.find((field) => field.id === "email")?.value;
                if (typeof inputEmail !== "string") {
                  return {
                    status: "GENERAL_ERROR",
                    message: "A valid email is required",
                  };
                }
                const sessionUserId = input.session.getUserId();
                const tenantId = input.tenantId;
                if (input.session.getTenantId() !== tenantId) {
                  return {
                    status: "GENERAL_ERROR",
                    message: "Cannot add a password across tenants",
                  };
                }
                const userObject = await SuperTokens.getUser(sessionUserId);
                const ownsVerifiedEmail = userObject?.loginMethods.some(
                  (loginMethod) =>
                    loginMethod.tenantIds.includes(tenantId) &&
                    loginMethod.verified &&
                    loginMethod.hasSameEmailAs(inputEmail),
                );
                if (!ownsVerifiedEmail) {
                  return {
                    status: "GENERAL_ERROR",
                    message: "Cannot use this email to add a password for this user",
                  };
                }
              }
              return await originalImplementation.signUpPOST!(input);
            },
          };
        },
      },
    }),
    Session.init({
      /* ... */
    }),
  ],
});
from typing import Any, Dict, List, Optional, Union

from supertokens_python.asyncio import get_user
from supertokens_python.recipe import emailpassword
from supertokens_python.recipe.emailpassword.interfaces import (
    APIInterface,
    APIOptions,
    EmailAlreadyExistsError,
    SignUpPostNotAllowedResponse,
    SignUpPostOkResult,
)
from supertokens_python.recipe.emailpassword.types import FormField
from supertokens_python.recipe.session.interfaces import SessionContainer
from supertokens_python.types import GeneralErrorResponse


def override_emailpassword_apis(original_implementation: APIInterface) -> APIInterface:
    original_sign_up_post = original_implementation.sign_up_post

    async def sign_up_post(
        form_fields: List[FormField],
        tenant_id: str,
        session: Optional[SessionContainer],
        should_try_linking_with_session_user: Optional[bool],
        api_options: APIOptions,
        user_context: Dict[str, Any],
    ) -> Union[
        SignUpPostOkResult,
        EmailAlreadyExistsError,
        SignUpPostNotAllowedResponse,
        GeneralErrorResponse,
    ]:
        if session is not None:
            input_email = next(field.value for field in form_fields if field.id == "email")
            user = await get_user(session.get_user_id(), user_context)
            owns_verified_email = user is not None and any(
                tenant_id in login_method.tenant_ids
                and login_method.verified
                and login_method.has_same_email_as(input_email)
                for login_method in user.login_methods
            )
            if session.get_tenant_id() != tenant_id or not owns_verified_email:
                return GeneralErrorResponse(
                    message="Cannot use this email to add a password for this user"
                )

        return await original_sign_up_post(
            form_fields,
            tenant_id,
            session,
            should_try_linking_with_session_user,
            api_options,
            user_context,
        )

    original_implementation.sign_up_post = sign_up_post
    return original_implementation


emailpassword.init(
    override=emailpassword.EmailPasswordOverrideConfig(
        apis=override_emailpassword_apis
    )
)

See also

API reference

API schema and response details