Skip to content
Esc
navigateopen⌘Jpreview
Dashboard
On this page

Passkeys as an MFA Factor

Require WebAuthn passkeys as a second authentication factor after email/password, social login, or another first factor.

Overview

This guide shows how to implement an MFA policy that requires all users to use WebAuthn before they get access to your application.

For standalone passwordless sign-in with passkeys, use the Passkey Authentication guide instead.

Before you start

The tutorial assumes that the first factor is email password or social login, but the same set of steps are applicable for other first factor types.

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.

Steps

Tenant setup

1. Configure the backend

To start with, we configure the backend in the following way:

import supertokens from "supertokens-node";
import ThirdParty from "supertokens-node/recipe/thirdparty";
import EmailPassword from "supertokens-node/recipe/emailpassword";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import webauthn from "supertokens-node/recipe/webauthn";
import Session from "supertokens-node/recipe/session";

supertokens.init({
  supertokens: {
    connectionURI: "...",
  },
  appInfo: {
    appName: "...",
    apiDomain: "...",
    websiteDomain: "...",
  },
  recipeList: [
    Session.init(),
    ThirdParty.init({
      //...
    }),
    EmailPassword.init({
      //...
    }),
    webauthn.init(),
    MultiFactorAuth.init({
      firstFactors: [MultiFactorAuth.FactorIds.EMAILPASSWORD, MultiFactorAuth.FactorIds.THIRDPARTY],
      override: {
        functions: (originalImplementation) => {
          return {
            ...originalImplementation,
            getMFARequirementsForAuth: async function (input) {
              // Change this implementation if you want to require webauthn only for specific users
              return [MultiFactorAuth.FactorIds.WEBAUTHN];
            },
          };
        },
      },
    }),
  ],
});
from typing import Any, Awaitable, Callable, Dict, List, Optional, Union

from supertokens_python import init, InputAppInfo, SupertokensConfig
from supertokens_python.recipe import (
    accountlinking,
    emailpassword,
    multifactorauth,
    session,
    thirdparty,
    webauthn,
)
from supertokens_python.recipe.accountlinking.types import (
    AccountInfoWithRecipeIdAndUserId,
    ShouldAutomaticallyLink,
    ShouldNotAutomaticallyLink,
)
from supertokens_python.recipe.multifactorauth.types import (
    FactorIds,
    OverrideConfig,
    MFARequirementList,
)
from supertokens_python.recipe.multifactorauth.interfaces import RecipeInterface
from supertokens_python.recipe.session import SessionContainer
from supertokens_python.types import User


async def should_link_webauthn_mfa_account(
    new_account_info: AccountInfoWithRecipeIdAndUserId,
    user: Optional[User],
    current_session: Optional[SessionContainer],
    tenant_id: str,
    user_context: Dict[str, Any],
) -> Union[ShouldNotAutomaticallyLink, ShouldAutomaticallyLink]:
    if current_session is None or current_session.get_tenant_id() != tenant_id:
        return ShouldNotAutomaticallyLink()

    is_making_session_user_primary = (
        user is None
        and new_account_info.recipe_user_id is not None
        and new_account_info.recipe_user_id.get_as_string()
        == current_session.get_recipe_user_id().get_as_string()
    )
    is_linking_webauthn_to_session_user = (
        new_account_info.recipe_id == "webauthn"
        and user is not None
        and user.id == current_session.get_user_id()
    )

    if (
        not is_making_session_user_primary
        and not is_linking_webauthn_to_session_user
    ):
        return ShouldNotAutomaticallyLink()

    return ShouldAutomaticallyLink(should_require_verification=True)


def override_functions(original_implementation: RecipeInterface):
    async def get_mfa_requirements_for_auth(
        tenant_id: str,
        access_token_payload: Dict[str, Any],
        completed_factors: Dict[str, int],
        user: Callable[[], Awaitable[User]],
        factors_set_up_for_user: Callable[[], Awaitable[List[str]]],
        required_secondary_factors_for_user: Callable[[], Awaitable[List[str]]],
        required_secondary_factors_for_tenant: Callable[[], Awaitable[List[str]]],
        user_context: Dict[str, Any],
    ) -> MFARequirementList:
	# Change this implementation if you want to require webauthn only for specific users
        return [FactorIds.WEBAUTHN]

    original_implementation.get_mfa_requirements_for_auth = (
        get_mfa_requirements_for_auth
    )
    return original_implementation


init(
    app_info=InputAppInfo(
        app_name="Example App",
        api_domain="http://localhost:3001",
        website_domain="http://localhost:3000",
    ),
    supertokens_config=SupertokensConfig(
        connection_uri="http://localhost:3567",
    ),
    framework="fastapi",
    recipe_list=[
        session.init(),
        thirdparty.init(),
        emailpassword.init(),
        accountlinking.init(
            should_do_automatic_account_linking=should_link_webauthn_mfa_account
        ),
        webauthn.init(),
        multifactorauth.init(
            first_factors=[FactorIds.EMAILPASSWORD, FactorIds.THIRDPARTY],
            override=OverrideConfig(functions=override_functions),
        ),
    ],
)

The MFA recipe override is required to indicate that webauthn must be completed before the user can access the app.

Once the user finishes the first factor (for example, with emailpassword), their session access token payload will look like this:

{
  "st-mfa": {
    "c": {
      "emailpassword": 1702877939
    },
    "v": false
  }
}

The v being false indicates that there are still factors that are pending. After the user has finished webauthn, the payload will look like:

{
  "st-mfa": {
    "c": {
      "emailpassword": 1702877939,
      "webauthn": 1702877999
    },
    "v": true
  }
}

Indicating that the user has finished all required factors, and should be allowed to access the app.

2. Authorize account linking on the backend

shouldTryLinkingWithSessionUser: true in the client calls below only asks the backend to try linking. It is not an authorization decision. The backend AccountLinking policy must decide whether linking is allowed.

SuperTokens automatically initializes AccountLinking with a deny-by-default policy when you omit the recipe. To use WebAuthn as a second factor, initialize one explicitly configured AccountLinking recipe in the same recipe list. This replaces the automatic default; do not add a second initialization. The Python examples above already include this policy. For Node.js, add the configuration below to the recipe list shown above. If you already configure account linking, merge these checks into that policy.

The following policy only permits linking for the current session and tenant. It also requires verified account information. SuperTokens and the Core still perform the authoritative conflict checks and reject linking if the recipe user or account information belongs to another primary user.

The single-tenant and multi-tenant Python examples above already initialize the configured accountlinking recipe exactly once. Do not initialize it again.

import { RecipeUserId, User } 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";

const accountLinkingForWebAuthnMFA = AccountLinking.init({
  shouldDoAutomaticAccountLinking: async (
    newAccountInfo: AccountInfoWithRecipeId & { recipeUserId?: RecipeUserId },
    user: User | undefined,
    session: SessionContainerInterface | undefined,
    tenantId: string,
  ) => {
    if (session === undefined || session.getTenantId() !== tenantId) {
      return { shouldAutomaticallyLink: false };
    }

    const sessionRecipeUserId = session.getRecipeUserId().getAsString();
    const isMakingSessionUserPrimary =
      user === undefined && newAccountInfo.recipeUserId?.getAsString() === sessionRecipeUserId;
    const isLinkingWebAuthnToSessionUser = newAccountInfo.recipeId === "webauthn" && user?.id === session.getUserId();

    if (!isMakingSessionUserPrimary && !isLinkingWebAuthnToSessionUser) {
      return { shouldAutomaticallyLink: false };
    }

    return {
      shouldAutomaticallyLink: true,
      shouldRequireVerification: true,
    };
  },
});

// Add accountLinkingForWebAuthnMFA once to the recipeList passed to supertokens.init.

3. Configure the WebAuthn RP ID and origin

WebAuthn validates the browser origin independently of your API domain. If your website is https://app.example.com and your API is https://api.example.com, use the website origin for origin. The RP ID must be the website hostname (app.example.com) or a registrable parent domain (example.com) whose scope you intentionally accept. Production origins must use HTTPS.

For tenant custom domains, keep the allowed RP ID and exact origin in server-side configuration or a trusted database indexed by the validated tenant ID. Reject unknown tenants. Never derive or reflect either value from Origin, Host, X-Forwarded-Host, or other request headers: an attacker may control those headers, and changing RP values can break credential scoping or allow ceremonies for an unintended domain.

import WebAuthn from "supertokens-node/recipe/webauthn";

const relyingPartyByTenant: Record<string, { relyingPartyId: string; origin: string }> = {
  public: {
    relyingPartyId: "example.com",
    origin: "https://app.example.com",
  },
  customer1: {
    relyingPartyId: "login.customer.example",
    origin: "https://login.customer.example",
  },
};

function getRelyingParty(tenantId: string) {
  const relyingParty = relyingPartyByTenant[tenantId];
  if (relyingParty === undefined) {
    throw new Error("WebAuthn is not configured for this tenant");
  }
  return relyingParty;
}

const webAuthnWithTrustedRelyingParties = WebAuthn.init({
  getRelyingPartyId: async ({ tenantId }) => getRelyingParty(tenantId).relyingPartyId,
  getOrigin: async ({ tenantId }) => getRelyingParty(tenantId).origin,
});

// Use webAuthnWithTrustedRelyingParties instead of webauthn.init() in the recipeList above.
from typing import Dict, Optional

from supertokens_python.framework import BaseRequest
from supertokens_python.recipe import webauthn
from supertokens_python.recipe.webauthn import WebauthnConfig
from supertokens_python.types.base import UserContext

relying_party_by_tenant: Dict[str, Dict[str, str]] = {
    "public": {
        "relying_party_id": "example.com",
        "origin": "https://app.example.com",
    },
    "customer1": {
        "relying_party_id": "login.customer.example",
        "origin": "https://login.customer.example",
    },
}


def get_relying_party(tenant_id: str) -> Dict[str, str]:
    relying_party = relying_party_by_tenant.get(tenant_id)
    if relying_party is None:
        raise ValueError("WebAuthn is not configured for this tenant")
    return relying_party


async def get_relying_party_id(
    *,
    tenant_id: str,
    request: Optional[BaseRequest],
    user_context: UserContext,
) -> str:
    return get_relying_party(tenant_id)["relying_party_id"]


async def get_origin(
    *,
    tenant_id: str,
    request: Optional[BaseRequest],
    user_context: UserContext,
) -> str:
    return get_relying_party(tenant_id)["origin"]


web_authn_with_trusted_relying_parties = webauthn.init(
    config=WebauthnConfig(
        get_relying_party_id=get_relying_party_id,
        get_origin=get_origin,
    )
)

# Use web_authn_with_trusted_relying_parties instead of webauthn.init() in the recipe_list above.

4. Configure the frontend

UI type

We start by modifying the init function call on the frontend like so:

You will have to make changes to the auth route config, as well as to the supertokens-web-js SDK config at the root of your application:

This change is in your auth route config.

import supertokens from "supertokens-auth-react";
import MultiFactorAuth from "supertokens-auth-react/recipe/multifactorauth";
import webauthn from "supertokens-auth-react/recipe/webauthn";

supertokens.init({
  appInfo: {
    appName: "...",
    apiDomain: "...",
    websiteDomain: "...",
  },
  recipeList: [
    // other recipes..
    webauthn.init(),
    MultiFactorAuth.init({
      firstFactors: [MultiFactorAuth.FactorIds.EMAILPASSWORD, MultiFactorAuth.FactorIds.THIRDPARTY],
    }),
  ],
});
// this goes in the auth route config of your frontend app (once the pre-built UI script has been loaded)
supertokensUIInit({
  appInfo: {
    appName: "...",
    apiDomain: "...",
    websiteDomain: "...",
  },
  recipeList: [
    // other recipes..
    supertokensUIWebAuthn.init(),
    supertokensUIMultiFactorAuth.init({
      firstFactors: [
        supertokensUIMultiFactorAuth.FactorIds.EMAILPASSWORD,
        supertokensUIMultiFactorAuth.FactorIds.THIRDPARTY,
      ],
    }),
  ],
});

This change goes in the supertokens-web-js SDK config at the root of your application:

On the frontend, the MultiFactorAuth recipe intialization only requires the first factors to be configured. The secondary factors will be determined based on a requiest to the backend.

Add the WebAuthn pre-built UI to render the SuperTokens component:

import { SuperTokensWrapper } from "supertokens-auth-react";
import { getSuperTokensRoutesForReactRouterDom } from "supertokens-auth-react/ui";
import { WebauthnPreBuiltUI } from "supertokens-auth-react/recipe/webauthn/prebuiltui";
import { MultiFactorAuthPreBuiltUI } from "supertokens-auth-react/recipe/multifactorauth/prebuiltui";
import reactRouterDOM, { Routes, BrowserRouter as Router, Route } from "react-router-dom";

function App() {
  return (
    <SuperTokensWrapper>
      <div className="App">
        <Router>
          <div className="fill">
            <Routes>
              {getSuperTokensRoutesForReactRouterDom(reactRouterDOM, [
                /* ... */ WebauthnPreBuiltUI,
                MultiFactorAuthPreBuiltUI,
              ])}
              // ... other routes
            </Routes>
          </div>
        </Router>
      </div>
    </SuperTokensWrapper>
  );
}
import { SuperTokensWrapper } from "supertokens-auth-react";
import { canHandleRoute, getRoutingComponent } from "supertokens-auth-react/ui";
import { WebauthnPreBuiltUI } from "supertokens-auth-react/recipe/webauthn/prebuiltui";
import { MultiFactorAuthPreBuiltUI } from "supertokens-auth-react/recipe/multifactorauth/prebuiltui";

function App() {
  if (canHandleRoute([/* ... */ WebauthnPreBuiltUI, MultiFactorAuthPreBuiltUI])) {
    return getRoutingComponent([/* ... */ WebauthnPreBuiltUI, MultiFactorAuthPreBuiltUI]);
  }
  return <SuperTokensWrapper>{/*Your app*/}</SuperTokensWrapper>;
}

That’s it! :tada:

Based on this configuration, users first access the authentication form which shows the emailpassword and thirdparty options. After first factor completion, they access the WebAuthn form to finalize the authentication attempt.

API reference

API schema and response details