File length: 28305 # Additional Verification - Multi Factor Authentication - Implement step-up authentication Source: https://supertokens.com/docs/additional-verification/mfa/step-up-auth ## 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 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](/docs/additional-verification/mfa/important-concepts). ### Prerequisites Step-up authentication supports the following factors: - `TOTP` - `WebAuthn/Passkeys` - 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. ```tsx 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... } ); ``` ```tsx let server = Hapi.server({ port: 8000 }); server.route({ path: "/update-blog", method: "post", options: { pre: [ { method: verifySession(), }, ], }, handler: 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... } }) ``` ```tsx let fastify = Fastify(); fastify.post("/update-blog", { preHandler: 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... }); ``` ```tsx async function updateBlog(awsEvent: SessionEvent) { let mfaClaim = await awsEvent.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... }; exports.handler = verifySession(updateBlog); ``` ```tsx let router = new KoaRouter(); router.post("/update-blog", verifySession(), async (ctx: SessionContext, next) => { let mfaClaim = await ctx.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... }); ``` ```tsx class Example { constructor(@inject(RestBindings.Http.CONTEXT) private ctx: MiddlewareContext) { } @post("/update-blog") @intercept(verifySession()) @response(200) async handler() { let mfaClaim = await (this.ctx as any).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... } } ``` ```tsx // highlight-start export default async function example(req: SessionRequest, res: any) { await superTokensNextWrapper( async (next) => { await verifySession()(req, res, next); }, req, 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 await superTokensNextWrapper( async (next) => { 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, }, }] }) }, req, res ) } // continue with API logic... } ``` ```tsx // @ts-ignore SuperTokens.init(backendConfig()); export function POST(request: NextRequest) { return withSession(request, async (err, session) => { if (err) { return NextResponse.json(err, { status: 500 }); } let mfaClaim = await 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 const error = 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, }, }] }) return NextResponse.json(error, { status: 403 }); } // continue with API logic... return NextResponse.json({}) }); } ``` ```tsx // @ts-ignore @Controller() export class ExampleController { @Post('example') @UseGuards(new AuthGuard()) async postExample(@Session() session: SessionContainer): Promise { let mfaClaim = await 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... return true; } } ``` :::note At the moment this feature is not supported through the Go SDK. ::: ```python ### 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. ```ts supertokens.init({ supertokens: { connectionURI: "..." }, appInfo: { appName: "...", apiDomain: "...", websiteDomain: "..." }, recipeList: [ MultiFactorAuth.init({ firstFactors: [/*...*/], override: { functions: (originalImplementation) => { return { ...originalImplementation, // highlight-start 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, }, }] }) } } } // highlight-end } } } }) ] }) ``` :::note At the moment this feature is not supported through the Go SDK. ::: ```python async function redirectToTotpSetupScreen() { MultiFactorAuth.redirectToFactor({ factorId: "totp", stepUp: true, redirectBack: true, }) } ``` - In the snippet above, redirect to the [TOTP factor setup screen](https://master--6571be2867f75556541fde98.chromatic.com/?path=/story/totp-mfa--device-setup). Set the `stepUp` argument to `true` otherwise the MFA screen would detect that the user has already completed basic MFA requirements and would not show the verification screen. Set the `redirectBack` argument to `true` 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. To add a new device, you can redirect the user to `/{websiteBasePath}/mfa/totp?setup=true&redirectToPath={currentPath}` from your settings page. This shows the [TOTP factor setup screen](https://master--6571be2867f75556541fde98.chromatic.com/?path=/story/totp-mfa--device-setup) to the user. The `redirectToPath` query parameter also tells the SDK to redirect the user back to the current page after they have finished creating the device. #### Show the factor in a popup Checkout [the documentation](/docs/additional-verification/mfa/embed-the-prebuilt-ui) for embedding the pre-built UI factor components in a page or a popup. You can check for this structure and the `factorId` to decide what factor to show on the frontend. ### 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. ```tsx const VerifiedRoute = (props: React.PropsWithChildren) => { return ( {props.children} ); } function InvalidClaimHandler(props: React.PropsWithChildren) { 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
You need to complete TOTP before seeing this page. Please click here to finish to proceed.
} // the user has finished TOTP, so we can render the children return
{props.children}
; } ``` - 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 replaces `Date.now()`. This accounts for any clock skew that may exist between the frontend and the backend server.
```tsx async function shouldLoadRoute(): Promise { if (await Session.doesSessionExist()) { let validationErrors = await Session.validateClaims(); if (validationErrors.length === 0) { // since all default claim validators have passed, we now check for if the user has finished TOTP // within the last 5 mins let mfaClaimValue = await Session.getClaimValue({ claim: MultiFactorAuthClaim }); let totpCompletedTime = mfaClaimValue?.c["totp"]; if (totpCompletedTime === undefined || totpCompletedTime < (DateProviderReference.getReferenceOrThrow().dateProvider.now() - 1000 * 60 * 5)) { // ths user needs to complete TOTP since it's been more than 5 mins since they completed it. return false; } return true; } else { // handle other validation failure events... } } // a session does not exist, or email is not verified return false } ``` - In your protected routes, you need to first check if a session exists, and then call the Session.validateClaims function as shown above. If that passes, it means all the default claim validators have passed (checks applied to all routes in general), and then perform the step-up auth check. - For checking for step-up auth, get the MFA claim value from the session and then check if TOTP completed within the last 5 minutes. Only if it did, return true, else return false. - Notice that the `DateProviderReference` class exported by SuperTokens replaces `Date.now()`. This accounts for any clock skew that may exist between the frontend and the backend server.
```tsx async function shouldLoadRoute(): Promise { if (await Session.doesSessionExist()) { let validationErrors = await Session.validateClaims(); if (validationErrors.length === 0) { // since all default claim validators have passed, we now check for if the user has finished TOTP // within the last 5 mins let mfaClaimValue = await Session.getClaimValue({ claim: MultiFactorAuthClaim }); let totpCompletedTime = mfaClaimValue?.c["totp"]; if (totpCompletedTime === undefined || totpCompletedTime < (DateProviderReference.getReferenceOrThrow().dateProvider.now() - 1000 * 60 * 5)) { // ths user needs to complete TOTP since it's been more than 5 mins since they completed it. return false; } return true; } else { // handle other validation failure events... } } // a session does not exist, or email is not verified return false } ``` ```tsx async function shouldLoadRoute(): Promise { if (await supertokensSession.doesSessionExist()) { let validationErrors = await supertokensSession.validateClaims(); if (validationErrors.length === 0) { // since all default claim validators have passed, we now check for if the user has finished TOTP // within the last 5 mins let mfaClaimValue = await supertokensSession.getClaimValue({ claim: supertokensMultiFactorAuth.MultiFactorAuthClaim }); let totpCompletedTime = mfaClaimValue?.c["totp"]; if (totpCompletedTime === undefined || totpCompletedTime < (supertokensDateProviderReference.DateProviderReference.getReferenceOrThrow().dateProvider.now() - 1000 * 60 * 5)) { // ths user needs to complete TOTP since it's been more than 5 mins since they completed it. return false; } return true; } else { // handle other validation failure events... } } // a session does not exist, or email is not verified return false } ``` - In your protected routes, you need to first check if a session exists, and then call the Session.validateClaims function as shown above. If that passes, it means all the default claim validators have passed (checks applied to all routes in general), and then perform the step-up auth check. - For checking for step-up auth, get the MFA claim value from the session and then check if TOTP completed within the last 5 minutes. Only if it did, return true, else return false. - Notice that the `DateProviderReference` class exported by SuperTokens replaces `Date.now()`. This accounts for any clock skew that may exist between the frontend and the backend server. ```tsx async function checkIfMFAIsCompleted() { if (await SuperTokens.doesSessionExist()) { // highlight-start let isMFACompleted: boolean = (await SuperTokens.getAccessTokenPayloadSecurely())["st-mfa"].v; if (isMFACompleted) { let completedFactors = (await SuperTokens.getAccessTokenPayloadSecurely())["st-mfa"].c; if (completedFactors["totp"] === undefined || completedFactors["totp"] < (Date.now() - 1000*60*5)) { // user has not finished TOTP MFA in the last 5 minutes } } else { // You can check the `c` object from ["st-mfa"] prop to see which factors have been completed by the user } // highlight-end } } ``` ```kotlin } } } catch (e: Exception) { // Handle exceptions such as ClassCastException or JSONException } } } ``` ```swift if isMFACompleted { // All required factors for MFA have been completed if let mfaCompletedFactors = mfaObject["c"] as? [String: Any], let totpTime = mfaCompletedFactors["totp"] as? Double { // Corrected unwrapping of mfaCompletedFactors and casting of totpTime if totpTime < (Date().timeIntervalSince1970 - 1000*60*5) { // user has not finished TOTP MFA in the last 5 minutes } } } else { // You can check the `c` object from ["st-mfa"] prop to see which factors have been completed by the user } } } } ``` ```dart Future checkIfMFAIsCompleted() async { var accessTokenPayload = await SuperTokens.getAccessTokenPayloadSecurely(); if (accessTokenPayload.containsKey("st-mfa")) { Map mfaObject = accessTokenPayload["st-mfa"]; if (mfaObject.containsKey("v")) { bool isMFACompleted = mfaObject["v"] as bool; // Casting to bool if (isMFACompleted) { // All required factors for MFA have been completed Map mfaCompletedFactors = mfaObject["c"]; if (mfaCompletedFactors["totp"] == null || mfaCompletedFactors["totp"] < (DateTime.now().millisecondsSinceEpoch - 1000 * 60 * 5)) { // user has not finished TOTP MFA in the last 5 minutes } } else { // You can check the `c` object from ["st-mfa"] prop to see which factors have been completed by the user } } } } ``` - In your protected routes, you need to first check if a session exists, and then check that the user has finished all the basic MFA factors for logging in (by checking the value of the `v` boolean in the MFA claim session). If that passes, then perform the step-up auth check. - For checking for step-up auth, get the MFA claim value from the session, and then check if TOTP completed within the last 5 minutes. Only if it did, return true, else return false. ---