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.
Steps
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.
In a multi tenancy setup, you may want to enable WebAuthn for all users, across all tenants, or for all users within specific tenants. For enabling for all users across all tenants, it’s the same steps as in the single tenant setup section above, so in this section, we will focus on enabling WebAuthn for all users within specific tenants.
To start, we will initialise the WebAuthn and the MultiFactorAuth recipes 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(),
],
});from typing import Any, Dict, 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.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)
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(),
],
)Unlike the single tenant setup, we do not provide any config to the MultiFactorAuth recipe cause all the necessary configuration will be done on a tenant level.
To configure WebAuthn requirement for a tenant, we can call the following API:
import Multitenancy from "supertokens-node/recipe/multitenancy";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
async function createNewTenant() {
let resp = await Multitenancy.createOrUpdateTenant("customer1", {
firstFactors: [MultiFactorAuth.FactorIds.EMAILPASSWORD, MultiFactorAuth.FactorIds.THIRDPARTY],
requiredSecondaryFactors: [MultiFactorAuth.FactorIds.WEBAUTHN],
});
if (resp.createdNew) {
// Tenant created successfully
} else {
// Existing tenant's config was modified.
}
}from supertokens_python.recipe.multitenancy.asyncio import create_or_update_tenant
from supertokens_python.recipe.multitenancy.interfaces import TenantConfigCreateOrUpdate
from supertokens_python.recipe.multifactorauth.types import FactorIds
async def create_new_tenant():
resp = await create_or_update_tenant(
"customer1", TenantConfigCreateOrUpdate(
first_factors=[FactorIds.EMAILPASSWORD],
required_secondary_factors=[FactorIds.WEBAUTHN],
)
)
if resp.created_new:
# Tenant created successfully
pass
else:
# Existing tenant's config was modified
passfrom supertokens_python.recipe.multitenancy.syncio import create_or_update_tenant
from supertokens_python.recipe.multitenancy.interfaces import TenantConfigCreateOrUpdate
from supertokens_python.recipe.multifactorauth.types import FactorIds
def create_new_tenant():
resp = create_or_update_tenant(
"customer1", TenantConfigCreateOrUpdate(
first_factors=[FactorIds.EMAILPASSWORD],
required_secondary_factors=[FactorIds.WEBAUTHN],
)
)
if resp.created_new:
# Tenant created successfully
pass
else:
# Existing tenant's config was modified
pass- In the above, we set the
firstFactorsto["emailpassword", "thirdparty"]to indicate that the first factor can be eitheremailpasswordorthirdparty. - We set the
requiredSecondaryFactorsto["webauthn"]to indicate that WebAuthn is required for all users in this tenant. The default implementation ofgetMFARequirementsForAuthin theMultiFactorAuthtakes this into account.
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
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 Multitenancy from "supertokens-auth-react/recipe/multitenancy";
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(),
Multitenancy.init({
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
getTenantId: async (context) => {
return "TODO";
},
};
},
},
}),
],
});// 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(),
supertokensUIMultitenancy.init({
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
getTenantId: async (context) => {
return "TODO";
},
};
},
},
}),
],
});This change goes in the supertokens-web-js SDK config at the root of your application:
import SuperTokens from "supertokens-web-js";
import MultiFactorAuth from "supertokens-web-js/recipe/multifactorauth";
import WebAuthn from "supertokens-web-js/recipe/webauthn";
SuperTokens.init({
appInfo: {
apiDomain: "...",
apiBasePath: "...",
appName: "...",
},
recipeList: [
// other recipes...
MultiFactorAuth.init(),
WebAuthn.init(),
],
});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:
import SuperTokens from "supertokens-web-js";
import MultiFactorAuth from "supertokens-web-js/recipe/multifactorauth";
import WebAuthn from "supertokens-web-js/recipe/webauthn";
SuperTokens.init({
appInfo: {
apiDomain: "...",
apiBasePath: "...",
appName: "...",
},
recipeList: [
// other recipes...
MultiFactorAuth.init(),
WebAuthn.init(),
],
});On the frontend, the MultiFactorAuth recipe initialization only requires the first factors to be configured.
The secondary factors will be determined based on a request 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>;
}We start by initialising the MFA and WebAuthn recipe on the frontend like so:
import SuperTokens from "supertokens-web-js";
import MultiFactorAuth from "supertokens-web-js/recipe/multifactorauth";
import WebAuthn from "supertokens-web-js/recipe/webauthn";
SuperTokens.init({
appInfo: {
apiDomain: "...",
apiBasePath: "...",
appName: "...",
},
recipeList: [
// other recipes...
MultiFactorAuth.init(),
WebAuthn.init(),
],
});supertokens.init({
appInfo: {
apiDomain: "...",
apiBasePath: "...",
appName: "...",
},
recipeList: [
// other recipes...
supertokensMultiFactorAuth.init(),
supertokensWebAuthn.init(),
],
});After the first factor login, you should start by checking the access token payload and see if the MFA claim’s v boolean is false.
’If it’s not, then you can redirect the user to the application page.
If it’s false, the frontend then needs to call the MFA endpoint to get information about which factor the user should be asked to complete next.
Based on the initial backend configuration, the next array will contain ["webauthn"].
To complete the secondary factor you need to take into account if the users has previously configured a passkey or not.
You can determine this by checking if the alreadySetup array contains "webauthn".
Sign up flow
Support for this flow is not available in the mobile SDK. You will have to call the backend API directly.
First, call the Register WebAuthn Credential endpoint to register the passkey. Afterwards call the Sign Up with WebAuthn to complete the second factor sign up process.
import Webauthn from "supertokens-web-js/recipe/webauthn";
async function secondFactorSignUp(email: string, userContext: Record<string, any>) {
const response = await Webauthn.registerCredentialWithSignUp({
email,
shouldTryLinkingWithSessionUser: true,
userContext,
});
return response.status === "OK";
}async function secondFactorSignUp(email: string, userContext: Record<string, any>) {
const response = await supertokensWebAuthn.registerCredentialWithSignUp({
email,
shouldTryLinkingWithSessionUser: true,
userContext,
});
return response.status === "OK";
}Sign in flow
Support for this flow is not available in the mobile SDK. You will have to call the backend API directly.
Call the Sign in with WebAuthn endpoint to complete the secondary factor flow.
import Webauthn from "supertokens-web-js/recipe/webauthn";
async function secondFactorSignUp(userContext: Record<string, any>) {
const response = await Webauthn.authenticateCredentialWithSignIn({
shouldTryLinkingWithSessionUser: true,
userContext,
});
return response.status === "OK";
}async function secondFactorSignUp(userContext: Record<string, any>) {
const response = await supertokensWebAuthn.authenticateCredentialWithSignIn({
shouldTryLinkingWithSessionUser: true,
userContext,
});
return response.status === "OK";
}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.