Implement step-up authentication
Overview
Step-up authentication enforces the user to complete an authentication challenge before navigating to a page, or before doing a specific action. You can implement it with SuperTokens as full page navigation, or as popups on the current page.
Before you start
This feature is only available to paid users.
These instructions assume that you already have some knowledge of MFA. If you are not familiar with terms like authentication factors and challenges, please go through the MFA concepts page.
Prerequisites
Step-up authentication supports the following factors:
TOTP
- Password (available only for custom UI)
- Email or SMS
OTP
If you are using OAuth2 in your configuration, step-up authentication is not supported at the moment.
Steps
1. Add the backend validators
To protect sensitive APIs with step up auth, you need to check that the user has completed the required auth challenge within a certain amount of time.
If they haven't, you should return a 403
to the frontend which highlights which factor is necessary.
The frontend can then consume this and show the auth challenge to the user.
import { verifySession } from "supertokens-node/recipe/session/framework/express";
import express from "express";
import { SessionRequest } from "supertokens-node/framework/express";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import { Error as STError } from "supertokens-node/recipe/session"
let app = express();
app.post(
"/update-blog",
verifySession(),
async (req: SessionRequest, res) => {
let mfaClaim = await req.session!.getClaimValue(MultiFactorAuth.MultiFactorAuthClaim);
const totpCompletedTime = mfaClaim!.c[MultiFactorAuth.FactorIds.TOTP];
if (totpCompletedTime === undefined || totpCompletedTime < (Date.now() - 1000*60*5)) {
// this means that the user had completed the TOTP challenge more than 5 minutes ago
// so we should ask them to complete it again
throw new STError({
type: "INVALID_CLAIMS",
message: "User has not finished TOTP",
payload: [{
id: MultiFactorAuth.MultiFactorAuthClaim.key,
reason: {
message: "Factor validation failed: totp not completed",
factorId: MultiFactorAuth.FactorIds.TOTP,
},
}]
})
}
// continue with API logic...
}
);
- When calling the
verifySession
, SuperTokens makes sure that the session is valid and that the user has completed all the required auth factors at some point in time. This enforces the basic check that the user has finished MFA during login. - Further check if the user has finished the TOTP login method within the last 5 minutes. If they haven't, send back a 403 to the frontend for the frontend to handle.
- You can check other factor types in this was as well. For example, if you want to check that the user has done email OTP in the last 5 minutes, you can use the factor ID of
otp-email
, or if you want to check that the user has entered their account password in the last 5 minutes, you can checkemailpassword
factor ID. - If users have different login methods, and / or different MFA configurations, you may want to first check what factor applies to them. You can check their login method by fetching the user object using the
getUser
function from the SDK, and then matching thesession.getRecipeId()
to the login methods in the user object. Per the MFA factors, you can see which ones this user has enabled by using theMultiFactorAuth.getRequiredSecondaryFactorsForUser
function. For performance reasons, you may want to put this information in the session's access token payload of the user in thecreateNewSession
override function of the session recipe.
2. Prevent factor setup during step-up authentication
By default, SuperTokens allows a factor setup, such as creating a new TOTP device, as long as the user has a session and has completed all the MFA factors required during login. This opens up a security issue when it comes to completing step up auth. Consider the following scenario:
- The user has logged in and completed TOTP
- After 5 minutes, the user tries to do a sensitive action and the API for that fails with a 403 (cause of the check in step 1, above).
- The user sees the TOTP challenge on the frontend. However, instead of completing that, they call the create TOTP device API which would succeed and then use the new TOTP device to complete the factor challenge required for the API.
This allows someone malicious to bypass step up auth. To prevent this, override one of the MFA recipe functions on the backend. This enforces that the factor setup can only happen if the user is not in a step-up auth state.
import supertokens from "supertokens-node";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth"
import { Error as STError } from "supertokens-node/recipe/session"
supertokens.init({
supertokens: {
connectionURI: "..."
},
appInfo: {
appName: "...",
apiDomain: "...",
websiteDomain: "..."
},
recipeList: [
MultiFactorAuth.init({
firstFactors: [/*...*/],
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
assertAllowedToSetupFactorElseThrowInvalidClaimError: async (input) => {
await originalImplementation.assertAllowedToSetupFactorElseThrowInvalidClaimError(input);
let claimValue = await input.session.getClaimValue(MultiFactorAuth.MultiFactorAuthClaim);
if (claimValue === undefined || !claimValue.v) {
return
}
// if the above did not throw, it means that the user has logged in and has completed all the required
// factors for login. So now we check specifically for the step up auth case:
if (input.factorId === MultiFactorAuth.FactorIds.TOTP && (await input.factorsSetUpForUser).includes(MultiFactorAuth.FactorIds.TOTP)) {
// this is an example of checking for totp, but you can also use other factor IDs.
const totpCompletedTime = claimValue.c[MultiFactorAuth.FactorIds.TOTP];
if (totpCompletedTime === undefined || totpCompletedTime < (Date.now() - 1000 * 60 * 5)) {
// this means that the user had completed the TOTP challenge more than 5 minutes ago
// so we should ask them to complete it again
throw new STError({
type: "INVALID_CLAIMS",
message: "User has not finished TOTP",
payload: [{
id: MultiFactorAuth.MultiFactorAuthClaim.key,
reason: {
message: "Factor validation failed: totp not completed",
factorId: MultiFactorAuth.FactorIds.TOTP,
},
}]
})
}
}
}
}
}
}
})
]
})
- SuperTokens calls the function
assertAllowedToSetupFactorElseThrowInvalidClaimError
whenever the client calls an API to setup a new factor (for example, create a new TOTP device). Perform checks in this function and throw an error if necessary to prevent factor setup. - In the override logic, first call the original implementation and check that the
v
value in the MFA session claim istrue
. This throws / exits the function early if the user has not logged in yet (for example, they have finished the first factor, but not the required second factor). - Then check if the user has TOTP already setup for them, if they haven't, then allow the factor setup (otherwise the user would not be able to complete the step-up auth challenge). If they have, perform the same check as in step 1 - checking if the user has finished TOTP in the last 5 minutes or not. If they haven't, disallow factor setup.
The customisation above prevents the security issue highlighted in the beginning of this step.
3. Handle 403
on the frontend
The JSON body of the step-up auth claim failure looks like this:
{
"message": "invalid claim",
"claimValidationErrors": [
{
"id": "st-mfa",
"reason": {
"message": "Factor validation failed: totp not completed",
"factorId": "totp",
}
}
]
}
What type of UI are you using?
You can check for this structure and the factorId
to decide what factor to show on the frontend. You have two options to show the UI to the user:
Full page redirect to the factor
To redirect the user to as factor challenge page and then navigate them back to the current page, you can use the following function:
import MultiFactorAuth from 'supertokens-auth-react/recipe/multifactorauth';
async function redirectToTotpSetupScreen() {
MultiFactorAuth.redirectToFactor({
factorId: "totp",
stepUp: true,
redirectBack: true,
})
}
- In the snippet above, redirect to the TOTP factor setup screen. Set the
stepUp
argument totrue
otherwise the MFA screen would detect that the user has already completed basic MFA requirements and would not show the verification screen. Set theredirectBack
argument totrue
since the intention is to redirect back to the current page after the user has finished setting up the device. - You can also redirect the user to
/{websiteBasePath}/mfa/totp?setup=true&redirectToPath={currentPath}
if you don't want to use the above function.
Show the factor in a popup
Checkout the docs for embedding the pre-built UI factor components in a page / popup:
4. Check for step-up authentication on page navigation
Sometimes, you may want to ask users to complete step up auth before displaying a page on the frontend. This is a different scenario than the above steps cause. Here, you do not want to rely on an API call to fail. Instead, you want to check for the step-up auth condition before rendering the page itself.
To do this, read the access token payload on the frontend and check the completed time of the factor of interest before rendering the page. If the completed time is older than 5 minutes (as an example), redirect the user to the factor challenge page.
import React from "react";
import { SessionAuth, useClaimValue } from 'supertokens-auth-react/recipe/session';
import MultiFactorAuth from "supertokens-auth-react/recipe/multifactorauth";
import { DateProviderReference } from "supertokens-auth-react/utils/dateProvider"
const VerifiedRoute = (props: React.PropsWithChildren<any>) => {
return (
<SessionAuth>
<InvalidClaimHandler>
{props.children}
</InvalidClaimHandler>
</SessionAuth>
);
}
function InvalidClaimHandler(props: React.PropsWithChildren<any>) {
let claimValue = useClaimValue(MultiFactorAuth.MultiFactorAuthClaim);
if (claimValue.loading) {
return null;
}
let totpCompletedTime = claimValue.value?.c[MultiFactorAuth.FactorIds.TOTP]
if (totpCompletedTime === undefined || totpCompletedTime < (DateProviderReference.getReferenceOrThrow().dateProvider.now() - 1000 * 60 * 5)) {
return <div>You need to complete TOTP before seeing this page. Please <a href={"/auth/mfa/totp?stepUp=true&redirectToPath=" + window.location.pathname}>click here</a> to finish to proceed.</div>
}
// the user has finished TOTP, so we can render the children
return <div>{props.children}</div>;
}
- Check if the user has completed TOTP within the last 5 minutes or not. If not, show a message to the user, and ask them to complete TOTP.
- Notice that the
DateProviderReference
class exported by SuperTokens replacesDate.now()
. This accounts for any clock skew that may exist between the frontend and the backend server.