Paid Feature
This is a paid feature.
For self hosted users, Sign up to get a license key and follow the instructions sent to you by email. Using the dev license key is free. We only start charging you once you enable the feature in production using the provided production license key.
For managed service users, you can click on the "enable paid features" button on our dashboard, and follow the steps from there on. Once enabled, this feature is free on the provided development environment.
Step up auth
Step up auth is when you want the user to complete an auth challenge before navigating to a page, or before doing an action on a page.
SuperTokens allows you to implement step up auth using the following factors:
- TOTP
- Password (available only for custom UI)
- Email / SMS OTP
You can implement these as full page navigations, or as popups on the current page.
#
Step 1) Adding backend validatorsTo 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 required. The frontend can then consume this and show the auth challenge to the user.
- NodeJS
- GoLang
- Python
- Other Frameworks
Important
- Express
- Hapi
- Fastify
- Koa
- Loopback
- AWS Lambda / Netlify
- Next.js (Pages Dir)
- Next.js (App Dir)
- NestJS
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["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: "totp",
},
}]
})
}
// continue with API logic...
}
);
import Hapi from "@hapi/hapi";
import { verifySession } from "supertokens-node/recipe/session/framework/hapi";
import {SessionRequest} from "supertokens-node/framework/hapi";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import { Error as STError } from "supertokens-node/recipe/session"
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["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: "totp",
},
}]
})
}
// continue with API logic...
}
})
import Fastify from "fastify";
import { verifySession } from "supertokens-node/recipe/session/framework/fastify";
import { SessionRequest } from "supertokens-node/framework/fastify";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import { Error as STError } from "supertokens-node/recipe/session"
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["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: "totp",
},
}]
})
}
// continue with API logic...
});
import { verifySession } from "supertokens-node/recipe/session/framework/awsLambda";
import { SessionEvent } from "supertokens-node/framework/awsLambda";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import { Error as STError } from "supertokens-node/recipe/session"
async function updateBlog(awsEvent: SessionEvent) {
let mfaClaim = await awsEvent.session!.getClaimValue(MultiFactorAuth.MultiFactorAuthClaim);
const totpCompletedTime = mfaClaim!.c["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: "totp",
},
}]
})
}
// continue with API logic...
};
exports.handler = verifySession(updateBlog);
import KoaRouter from "koa-router";
import { verifySession } from "supertokens-node/recipe/session/framework/koa";
import { SessionContext } from "supertokens-node/framework/koa";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import { Error as STError } from "supertokens-node/recipe/session"
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["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: "totp",
},
}]
})
}
// continue with API logic...
});
import { inject, intercept } from "@loopback/core";
import { RestBindings, MiddlewareContext, post, response } from "@loopback/rest";
import { verifySession } from "supertokens-node/recipe/session/framework/loopback";
import Session from "supertokens-node/recipe/session";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import { Error as STError } from "supertokens-node/recipe/session"
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["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: "totp",
},
}]
})
}
// continue with API logic...
}
}
import { superTokensNextWrapper } from 'supertokens-node/nextjs'
import { verifySession } from "supertokens-node/recipe/session/framework/express";
import { SessionRequest } from "supertokens-node/framework/express";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import { Error as STError } from "supertokens-node/recipe/session"
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["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: "totp",
},
}]
})
},
req,
res
)
}
// continue with API logic...
}
import { NextResponse, NextRequest } from "next/server";
import SuperTokens from "supertokens-node";
import { withSession } from "supertokens-node/nextjs";
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import { backendConfig } from "@/app/config/backend";
import { Error as STError } from "supertokens-node/recipe/session"
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["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: "totp",
},
}]
})
return NextResponse.json(error, { status: 403 });
}
// continue with API logic...
return NextResponse.json({})
});
}
import { Controller, Post, UseGuards, Request, Response, Session } from "@nestjs/common";
import { SessionContainer, SessionClaimValidator } from "supertokens-node/recipe/session";
import { AuthGuard } from './auth/auth.guard';
import MultiFactorAuth from "supertokens-node/recipe/multifactorauth";
import { Error as STError } from "supertokens-node/recipe/session"
@Controller()
export class ExampleController {
@Post('example')
@UseGuards(new AuthGuard())
async postExample(@Session() session: SessionContainer): Promise<boolean> {
let mfaClaim = await session!.getClaimValue(MultiFactorAuth.MultiFactorAuthClaim);
const totpCompletedTime = mfaClaim!.c["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: "totp",
},
}]
})
}
// continue with API logic...
return true;
}
}
note
Coming soon.
note
Coming soon.
- When calling the
verifySession
, SuperTokens makes sure that the session is valid and that the user has completed all the requreied auth factors at some point in time. This enforces the basic check that the user has finished MFA during login. - We then further check that if the user has finished the TOTP login method within the last 5 mins. If they haven't, we 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 mins, 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 mins, 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 our SDK, and then matching thesession.getRecipeId()
to the login methods in the user object. As per the MFA factors, you can see which ones are enabled for this user 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.
#
Step 2) Preventing factor setup during step up authBy default, SuperTokens allows a factor setup (for example, 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 mins, 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 is shown 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 someome malicious to bypass step up auth. In order to prevent this, we need to override one fo the MFA recipe functions on the backend to enforce that the factor setup can only happen if the user is not in a step up auth state:
- NodeJS
- GoLang
- Python
- Other Frameworks
Important
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 === "totp" && (await input.factorsSetUpForUser).includes("totp")) {
// this is an example of checking for totp, but you can also use other factor IDs.
const totpCompletedTime = claimValue.c["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: "totp",
},
}]
})
}
}
}
}
}
}
})
]
})
note
Coming soon.
note
Coming soon.
- The function
assertAllowedToSetupFactorElseThrowInvalidClaimError
is called by SuperTokens whenever the client calls an API to setup a new factor (for example, create a new TOTP device). So we do our checks in this function and throw an error in case we have to to prevent factor setup. - In the override logic, we first call the original implementation and check that the
v
value in the MFA session claim istrue
. This will throw / exit the function early in case the user has not completely logged in yet (for example, they have finished the first factor, but not the required second factor). - Then we check if the user has TOTP already setup for them, if they haven't, then we allow the factor setup (otherwise the user would not be able to complete the step up auth challenge). If they have, we do the same check we did in step 1 - checking if the user has finished TOTP in the last 5 mins or not. If they haven't, we disallow factor setup.
The customisation above prevents the security issue highlighted in the beginning of this step.
403
on the frontend#
Step 3) Handling The JSON body of the step up auth claim failure will look like this:
{
"message": "invalid claim",
"claimValidationErrors": [
{
"id": "st-mfa",
"reason": {
"message": "Factor validation failed: totp not completed",
"factorId": "totp",
}
}
]
}
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 factorTo redirect the user to as factor challenge page and then navigate them back to the current page, you can use the following function:
- ReactJS
- Angular
- Vue
In order to add a new device, you can redirect the user to /{websiteBasePath}/mfa/totp?redirectToPath={currentPath}
from your settings page. This will show the TOTP factor setup screen to the user. The redirectToPath
query param will also tell our SDK to redirect the user back to the current page after they have finished creating the device.
import MultiFactorAuth from 'supertokens-auth-react/recipe/multifactorauth';
async function redirectToTotpSetupScreen() {
MultiFactorAuth.redirectToFactor("totp", false, true)
}
- In the snippet above, we redirect to the TOTP factor setup screen. The second argument represents a boolean for
forceSetup
which we set to false since we do not want to ask the user to create a new device. The third arg is alsotrue
since we want to redirect back to the current page after the user has finished setting up the device. - You can also just redirect the user to
/{websiteBasePath}/mfa/totp?redirectToPath={currentPath}
if you don't want to use the above function.
In order to add a new device, you can redirect the user to /{websiteBasePath}/mfa/totp?redirectToPath={currentPath}
from your settings page. This will show the TOTP factor setup screen to the user. The redirectToPath
query param will also tell our SDK to redirect the user back to the current page after they have finished creating the device.
#
Show the factor in a popupCheckout the docs for embedding the pre built UI factor components in a page / popup:
#
Step 4) Checking for step up auth on page navigationSometimes, you may want to ask users to complete step up auth before displaying a page on the frontend. This is a different scenario that the above steps cause here, you do not want to reply 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, we read the access token payload on the frontend and check the completed time of the factor we care about before rendering the page. If the completed time is older than 5 mins (as an example), we should redirect the user to the factor challenge page.
- ReactJS
- Angular
- Vue
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["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?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>;
}
- We check if the user has completed TOTP within the last 5 mins or not. If not, we show a message to the user, and ask them to complete TOTP.
- Notice that we use a
DateProviderReference
class exported by SuperTokens instead of just doingDate.now()
. This is done to take into account any clock skew that may exist between the frontend and the backend server.
import Session from "supertokens-web-js/recipe/session";
import { MultiFactorAuthClaim } from "supertokens-web-js/recipe/multifactorauth";
import { DateProviderReference } from "supertokens-web-js/utils/dateProvider";
async function shouldLoadRoute(): Promise<boolean> {
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 that are applied to all routes in general), and we can check for the step up auth check next.
- For checking for step up auth, we get the MFA claim value from the session and then check if TOTP was completed within the last 5 mins. Only if it was, we return true, else we return false.
- Notice that we use a
DateProviderReference
class exported by SuperTokens instead of just doingDate.now()
. This is done to take into account any clock skew that may exist between the frontend and the backend server.
import Session from "supertokens-web-js/recipe/session";
import { MultiFactorAuthClaim } from "supertokens-web-js/recipe/multifactorauth";
import { DateProviderReference } from "supertokens-web-js/utils/dateProvider";
async function shouldLoadRoute(): Promise<boolean> {
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 that are applied to all routes in general), and we can check for the step up auth check next.
- For checking for step up auth, we get the MFA claim value from the session and then check if TOTP was completed within the last 5 mins. Only if it was, we return true, else we return false.
- Notice that we use a
DateProviderReference
class exported by SuperTokens instead of just doingDate.now()
. This is done to take into account any clock skew that may exist between the frontend and the backend server.