Skip to main content
Which UI do you use?
Custom UI
Pre built UI

Option 2. Using claim validators

What are session claims?#

SuperTokens session has a property called accessTokenPayload. This is a JSON object that's stored in a user's session which can be accessed on the frontend and backend. The key-values in this JSON payload are called claims.

What are session claim validators?#

Session claim validators check if the the claims in the session meet a certain criteria before giving access to a resource.

Let's take two examples:

  • 2FA session claim validator: This validator checks if the session claims indicates that the user has completed both the auth factors or not.
  • Email verification claim validator: This checks if the user's session indicates if they have verified their email or not.

In either case, the claim validators base their checks on the claims (or properties) present in the session's access token payload. These claims can be added by you or by the SuperTokens SDK (ex, the user roles recipe adds the roles claims to the session).

This document will guide you through how to use prebuilt session claims and session claims validators as well as how to build your own.

Why do we need session claim validators?#

The claims in the payload represent the state of the user's access properties. Session claim validators ensure that the state is up to date when the claims are being checked.

For example, if during sign in, the user has the role of "user", this will be added to their session by SuperTokens. If during the course of their session, the user is upgraded to an "admin" role, then the session claim needs to be updated to reflect this as well. To do this automatically, the claim validator for roles will auto refresh the role in the session periodically. You can even specify that you want to force refresh the state when using the validator in your APIs.

Without a special construct of session claim validators, the updating of the session claims would have to be done manually by you (the developer), so to save you the time and effort, we introduced this concept.

Session claim interface#

On the backend#

Before we dive deep into claim validators, let's talk about session claim objects. These are objects that conform to an interface that allows SuperTokens to automatically add session claims to the access token payload. Here is the interface:


import { RecipeUserId } from "supertokens-node";

interface SessionClaim<T> {
readonly key: string;

fetchValue(userId: string, recipeUserId: RecipeUserId, tenantId: string, currentPayload: JSONObject | undefined, userContext: any): Promise<T | undefined>;

addToPayload_internal(payload: JSONObject, value: T, userContext: any): JSONObject;

removeFromPayloadByMerge_internal(payload: JSONObject, userContext: any): JSONObject;

removeFromPayload(payload: JSONObject, userContext: any): JSONObject;

getValueFromPayload(payload: JSONObject, userContext: any): T | undefined;

getLastRefetchTime(payload: JSONObject, userContext: any): number | undefined;
}
  • T represents a generic type. For a boolean claim (for example if the email is verified or not), the type of T is a boolean.

  • fetchValue is responsible for fetching the value of the claim from its source. For example, the email verification claim uses the EmailVerification.isEmailVerified function from the email verification recipe to return a boolean from this function.

  • addToPayload_internal function is responsible for adding the claim value to the input payload and returning the modified payload. The payload here represents the access token's payload. Some of the in built claims in the SDK modify the payload in the following way:

    {
    ...payload,
    "<key>": {
    "t": <current time in milliseconds>,
    "v": <value>
    }
    }

    The key variable is an input to the constructor. For the in built email verification claim, the value of key is "st-ev".

  • removeFromPayloadByMerge_internal function is responsible for modifying the input payload to remove the claim in such a way that if mergeIntoAccessTokenPayload is called, then it would remove that claim from the payload. This usually means modifying the payload like:

    {
    ...payload,
    "<key>": null
    }
  • removeFromPayload function is similar to the previous function, except that it deletes the key from the input payload entirely.

  • getValueFromPayload function is supposed to return the claim's value given the input payload. For the in built claims, it's usually payload[<key>][v] or undefined of the key doesn't exist in the payload.

  • getLastRefetchTime function returns the last time (in milliseconds) since this claim was fetched. If the claim doesn't exist in the payload, this function returns undefined.

The SDK provides a few base claim classes which make it easy for you to implement your own claims:

  • PrimitiveClaim: Can be used to add any primitive type value (boolean, string, number) to the session payload.
  • PrimitiveArrayClaim: Can be used to add any primitive array type value (boolean[], string[], number[]) to the session payload.
  • BooleanClaim: A special case of the PrimitiveClaim, used to add a boolean type claim.

Using these, we have built a few useful claims:

  • EmailVerificationClaim: This is used to store info about if the user has verified their email.
  • RolesClaim: This is used to store the list of roles associated with a user.
  • PermissionClaim: This is used to store the list of permissions associated with the user.

You can image all sorts of claims that can be built further:

  • If the user has completed 2FA or not
  • If the user has filled in all the profile info post sign up or not
  • The last time the user authenticated themselves (useful for if you want to ask the user for their password after a certain time period).

On the frontend#

Just like the backend, the frontend also has the concept of Session claim objects which need to conform to the following interface:

type SessionClaim<T> = {
refresh(): Promise<void>;

getValueFromPayload(payload: any): T | undefined;

getLastFetchedTime(payload: any): number | undefined;
};
  • The refresh function is responsible for refreshing the claim values in the session via an API call. The API call is expected to update the claim values if required.

  • getValueFromPayload helps with reading the value from the session claim.

  • getLastFetchedTime reads the claim to return the timestamp (in milliseconds) of the last time the claim was refreshed.

When used, these objects provide a way for the SuperTokens SDK to update the claim values as and when needed. For example, in the built-in email verification claim, the refresh function calls the backend API to check if the email is verified. That API in turn updates the session claim to reflect the email verification status. This way, even if the email was marked as verified in offline mode, the frontend will be able to get the email verification status update automatically.

Just like the backend SDK, the frontend SDK also exposes a few base claims:

Claim validator interface#

On the backend#

Once a claim is added to the session, we must specify the checks that need to run on them during session verification. For example, if we want an API to be guarded so that only admin roles can access them, we need a way to tell SuperTokens to do that check. This is where claim validators come into the picture. Here is the shape for a claim validator object:

type SessionClaimValidator = {
id: string;

claim: SessionClaim<any>;

shouldRefetch: (payload: any, userContext: any) => Promise<boolean>;

validate: (payload: any, userContext: any) => Promise<ClaimValidationResult>;
};

type ClaimValidationResult = { isValid: true } | { isValid: false; reason?: any };
  • The id is used to identify the session claim validator. This is useful to know which validator failed in case several of them are being checked at the same time. The value of this is usually the same as the claim object's key, but it can be set to anything else.

  • The claim property is a reference to the claim object that's associated with this validator. The shouldRefetch and validate functions will use claim.getValueFromPayload to fetch the claim value from the input payload.

  • shouldRefetch is a function which determines if the value of the claim should be fetched again. In the in built validators, this function usually returns true if the claim does not exist in the payload, or if it's too old.

  • validate function extracts the claim value from the input payload (usually using claim.getValueFromPayload), and determines if the validator check has passed or not. For example, if the validator is supposed to enforce that the user's email is verified, and if the claim value is false, then this function would return:

    {
    isValid: false,
    message: "wrong value",
    expectedValue: true,
    actualValue: false
    }

Using this interface and the claims interface, SuperTokens runs the following session claim validation process during session verification:

function validateSessionClaims(accessToken, claimValidators[]) {
payload = accessToken.getPayload();

// Step 1: refetch claims if required
foreach validator in claimValidators {
if (validator.shouldRefetch(payload)) {
claimValue = validator.claim.fetchValue(accessToken.sub)
payload = validator.claim.addToPayload_internal(payload, claimValue)
}
}

failedClaims = []

// Step 2: Validate all claims
foreach validator in claimValidators {
validationResult = validator.validate(payload)
if (!validationResult.isValid) {
failedClaims.push({id: validator.id, reason: validationResult.reason})
}
}

return failedClaims
}

The built-in base claims (PrimitiveClaim, PrimitiveArrayClaim, BooleanClaim) all expose a set of useful validators:

In all of the above claim validators, the maxAgeInSeconds input (which is optional) governs how often the session claim value should be refetched

  • A value of 0 will make it refetch the claim value each time a check happens.
  • If not passed, the claim will only be refetched if it's missing in the session. The in built claims like email verification or user roles claims have a default value of five mins, meaning that those claim values are refreshed from the database after every five mins.

On the frontend#

Just like the backend, the frontend too has session claim validators that conform to the following shape

type SessionClaimValidator = {
readonly id: string;

refresh(): Promise<void>;

shouldRefresh(accessTokenPayload: any): Promise<boolean> | boolean;

validate(
accessTokenPayload: any
): Promise<ClaimValidationResult> | ClaimValidationResult;

onFailureRedirection?: (({ userContext, reason }: { userContext: any; reason: any }) => Promise<string | undefined> | string | undefined);
showAccessDeniedOnFailure?: boolean;
}

type ClaimValidationResult = { isValid: true } | { isValid: false; reason?: any };
  • The refresh function is the same as the one in the frontend claim interface.
  • shouldRefresh is function which determines if the claim should be checked against the backend before calling validate. This usually returns true if the claim value is too old or if it is not present in the accessTokenPayload. When implementing this function for a SessionClaimValidator and using Date.now, it's advisable to use DateProvider.now from our SDK. This mitigates potential clock skew issues by accounting for the time difference between the frontend and backend. Use DateProvider as shown:
import { DateProviderReference } from "supertokens-auth-react/utils/dateProvider";
DateProviderReference.getReferenceOrThrow().dateProvider.now();
  • The validate function checks the accessTokenPayload for the value of the claim and returns an appropriate response.
  • Using the onFailureRedirection callback, you can choose to redirect the user to a specific url or path if the claim validation fails. The default value of this is undefined, which means that there will be no redirection on failure.
  • By setting showAccessDeniedOnFailure to false, you can choose to still show the contents of SessionAuth even if the claim validation fails. The default value of this is true, which means that if the SessionAuth wrapper is supplied with a an access denied screen component, it will display that.

The logic for how validators are run on the frontend is the same as on the backend:

  • First we refresh the claim if that claim's shouldRefresh returns true (and we do this for all the claims)
  • Then we call the validate function on the claims one by one to return a array of validation result.

In case validation fails, the SessionAuth component will:

  1. Automatically redirect the user to a related screen where the user can either resolve the issue or get more information if onFailureRedirection has been set up. For example, the email verification validator (in required mode) will redirect to the email verification screen, where the user can verify their email address.
  2. Show an access denied screen if a component was passed in the accessDeniedScreen prop and the validator doesn't have showAccessDeniedOnFailure set to false. E.g.: role and permission claim validatros will show the access denied screen.
  3. Render the children with the validation error added to the session context (in invalidClaims) if neither of the above was applicable. E.g.: the email verification validator in OPTIONAL mode.

And once again, just like in the backend, the frontend too exposes several helper functions like hasValue, includes, excludes, isTrue etc. for the base claim classes.

How to add or modify a claim in a session?#

note

There are "protected" claims, reserved for standard or supertokens specific use-cases. Trying to overwrite them in createNewSession or using mergeIntoAccessTokenPayload will result in errors.

They are: sub, iat, exp, sessionHandle, refreshTokenHash1, parentRefreshTokenHash1, antiCsrfToken

Once you have made your own session claim object, you need to add it to a session. There are two ways in which you can add them:

  • During session creation
  • Updating the session to add a claim after session creation

During session creation#

You need to override the createNewSession function to modify the access token payload like shown below:

import SuperTokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
import { UserRoleClaim } from "supertokens-node/recipe/userroles";

SuperTokens.init({
supertokens: {
connectionURI: "...",
},
appInfo: {
apiDomain: "...",
appName: "...",
websiteDomain: "..."
},
recipeList: [
// ...
Session.init({
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
createNewSession: async function (input) {
let userId = input.userId;

// This goes in the access token, and is available to read on the frontend.
input.accessTokenPayload = {
...input.accessTokenPayload,
...(await UserRoleClaim.build(input.userId, input.recipeUserId, input.tenantId, undefined, input.userContext))
};

/*
At this step, the access token paylaod looks like this:
{
...input.accessTokenPayload,
st-roles: {
v: ["admin"],
t: <current time in MS>
}
}
*/

return originalImplementation.createNewSession(input);
},
};
},
},
})
]
});

In the above code snippet, we take an example of manually adding the user roles claim to the session (note that this is done automatically for you if you initialise the user roles recipe).

The build function is a helper function which all claims have that does the following:

class Claim {
// other functions like fetchValue, getValueFromPayload etc..
function build(userId, recipeUserId, tenantId) {
claimValue = this.fetchValue(userId, recipeUserId, tenantId);
return this.addToPayload_internal({}, claimValue)
}
}

Post session creation#

Once you have the session container object (result of session verification), you can call the fetchAndSetClaim function on it to set the claim value in the session

import { SessionContainer } from "supertokens-node/recipe/session";
import { UserRoleClaim } from "supertokens-node/recipe/userroles";

async function addClaimToSession(session: SessionContainer) {
await session.fetchAndSetClaim(UserRoleClaim)
}

fetchAndSetClaim fetches the claim value using claim.fetchValue and adds it to the access token in the session.

There is also an offline version of fetchAndSetClaim exposed by the Session recipe which takes the claim and a sessionHandle. This will update that session's access token payload in the database, so when that session refreshes, the new access token will reflect that change.

Manually setting a claim's value in a session#

You can also manually set a claim's value in the session without it using the fetchValue function. This is useful for situations in which the fetchValue function doesn't read from a data source (ex: a database).

An example of this is the 2FA claim. The fetchValue function always returns false because there is no database entry that is updated when 2FA is completed - we simply update the session payload.

import { SessionContainer } from "supertokens-node/recipe/session";
import { BooleanClaim } from "supertokens-node/recipe/session/claims";

const SecondFactorClaim = new BooleanClaim({
fetchValue: () => false,
key: "2fa-completed",
});

async function mark2FAAsComplete(session: SessionContainer) {
await session.setClaimValue(SecondFactorClaim, true)
}

How to add a claim validator?#

In order for SuperTokens to check the claims during session verification, you need to add the claim validators in the backend / frontend SDK. On the backend, the claim validators will be run during session verification, and on the frontend, they will run when you use the <SessionAuth> component (for pre built UI), or when you call the Session.validateClaims function.

Adding a validator check globally#

This method allows you to add a claim validator such that it applies checks globally - for all your API / frontend routes. This is useful for claim validators like 2FA, when you want all users to be able to access the app only if they have completed 2FA. It also helps prevent development mistakes wherein someone may forget to explictly add the 2FA claim check for each route.

On the backend#

import SuperTokens from "supertokens-node";
import Session from "supertokens-node/recipe/session";
import { BooleanClaim } from "supertokens-node/recipe/session/claims";

const SecondFactorClaim = new BooleanClaim({
fetchValue: () => false,
key: "2fa-completed",
});

SuperTokens.init({
supertokens: {
connectionURI: "...",
},
appInfo: {
apiDomain: "...",
appName: "...",
websiteDomain: "..."
},
recipeList: [
// ...
Session.init({
override: {
functions: (originalImplementation) => {
return {
...originalImplementation,
getGlobalClaimValidators: async function (input) {
return [...input.claimValidatorsAddedByOtherRecipes, SecondFactorClaim.validators.isTrue()]
}
};
},
},
})
]
});

This will run the isTrue validator check on the SecondFactorClaim during each session verification and will only allow access to your APIs if this validator passes (i.e., the user has finished 2FA). If this validator fails, SuperTokens will send a 403 to the frontend.

note

The claim validators added this way do not run for the APIs exposed by the SuperTokens middleware.

On the frontend#

import SuperTokens from "supertokens-auth-react"
import Session, { BooleanClaim } from "supertokens-auth-react/recipe/session";

const SecondFactorClaim = new BooleanClaim({
id: "2fa-completed",
refresh: async () => {
// we do nothing here because refreshing the 2fa claim doesn't make sense
}
});

SuperTokens.init({
appInfo: {
apiDomain: "...",
appName: "...",
websiteDomain: "..."
},
recipeList: [
//...
Session.init({
override: {
functions: (oI) => {
return {
...oI,
getGlobalClaimValidators: function (input) {
return [...input.claimValidatorsAddedByOtherRecipes, SecondFactorClaim.validators.isTrue()]
}
}
}
}
})
]
});

Now whenever you wrap your component with the <SessionAuth> wrapper, SuperTokens will run the SecondFactorClaim.validators.isTrue() validator automatically and show an access denied screen if it fails. You can see how to change this behaviour further down, in the "Handling claim validation failures" section.

Adding a validator check to a specific route#

If you want a session claim validator to run only on certain routes, then you should use this method of adding them.

To illustrate this, we will be taking an example of user roles claim validator in which we will be giving access to a user only if they have the "admin" role.

On the backend#

import { verifySession } from "supertokens-node/recipe/session/framework/express";
import express from "express";
import { SessionRequest } from "supertokens-node/framework/express";
import UserRoles from "supertokens-node/recipe/userroles";

let app = express();

app.post(
"/update-blog",
verifySession({
overrideGlobalClaimValidators: async (globalValidators) => [
...globalValidators,
UserRoles.UserRoleClaim.validators.includes("admin"),
// UserRoles.PermissionClaim.validators.includes("edit")
],
}),
async (req: SessionRequest, res) => {
// All validator checks have passed and the user is an admin.
}
);
  • We add the UserRoleClaim validator to the verifySession function which makes sure that the user has an admin role.
  • The globalValidators represents other validators that apply to all API routes by default. This may include a validator that enforces that the user's email is verified (if enabled by you).
  • We can also add a PermissionClaim validator to enforce a permission.

For more complex access control, you can even extract the claim value from the session and then check the value yourself:

import express from "express";
import { verifySession } from "supertokens-node/recipe/session/framework/express";
import { SessionRequest } from "supertokens-node/framework/express";
import UserRoles from "supertokens-node/recipe/userroles";
import { Error as STError } from "supertokens-node/recipe/session"

let app = express();

app.post("/update-blog", verifySession(), async (req: SessionRequest, res) => {
const roles = await req.session!.getClaimValue(UserRoles.UserRoleClaim);

if (roles === undefined || !roles.includes("admin")) {
// this error tells SuperTokens to return a 403 to the frontend.
throw new STError({
type: "INVALID_CLAIMS",
message: "User is not an admin",
payload: [{
id: UserRoles.UserRoleClaim.key
}]
})
}
// user is an admin..
});

On the frontend#

Provide the overrideGlobalClaimValidators prop to the <SessionAuth> component as shown below

import React from "react";
import { SessionAuth } from 'supertokens-auth-react/recipe/session';
import { AccessDeniedScreen } from 'supertokens-auth-react/recipe/session/prebuiltui';
import { UserRoleClaim, /*PermissionClaim*/ } from 'supertokens-auth-react/recipe/userroles';

const AdminRoute = (props: React.PropsWithChildren<any>) => {
return (
<SessionAuth
accessDeniedScreen={AccessDeniedScreen}
overrideGlobalClaimValidators={(globalValidators) => [
...globalValidators, UserRoleClaim.validators.includes("admin"),
]
}>
{props.children}
</SessionAuth>
);
}

Above we are creating a generic component called AdminRoute which enforces that its child components can only be rendered if the user has the admin role.

In the AdminRoute component, we use the SessionAuth wrapper to ensure that the session exists. We also add the UserRoleClaim validator to the <SessionAuth> component which checks if the validators pass or not. If all validation passes, we render the props.children component. If the claim validation has failed, it will display the AccessDeniedScreen component instead of rendering the children. You can also pass your own custom component to the accessDeniedScreen prop.

note

You can extend the AdminRoute component to check for other types of validators as well. This component can then be reused to protect all of your app's components (In this case, you may want to rename this component to something more appropriate, like ProtectedRoute).

If you want to have more complex access control, you can get the roles list from the session as follows, and check the list yourself:

import Session from "supertokens-auth-react/recipe/session";
import {UserRoleClaim} from "supertokens-auth-react/recipe/userroles"

function ProtectedComponent() {
let claimValue = Session.useClaimValue(UserRoleClaim)
if (claimValue.loading || !claimValue.doesSessionExist) {
return null;
}
let roles = claimValue.value;
if (Array.isArray(roles) && roles.includes("admin")) {
// User is an admin
} else {
// User doesn't have any roles, or is not an admin..
}
}
caution

Unlike using the overrideGlobalClaimValidators prop, the useClaimValue function will not check for globally added claims. SuperTokens adds certain claims globally (for example the email verification claim in case you have enabled that recipe) which get checked only when running the <SessionAuth> wrapper is executed. Therefore, using useClaimValue is less favourable.

Handling claim validation failures#

Redirection#

You can redirect your users to a URL or path if a claim validator fails, by adding an onFailureRedirection callback to the validator. If this callback returns a string, the SDK will try to redirect the user to that URL.

import React from "react";
import { SessionAuth, useSessionContext } from 'supertokens-auth-react/recipe/session';
import { UserRoleClaim } from 'supertokens-auth-react/recipe/userroles';

const AdminRoute = (props: React.PropsWithChildren<any>) => {
return (
<SessionAuth
overrideGlobalClaimValidators={(globalValidators) =>
[
...globalValidators,
{
...UserRoleClaim.validators.includes("admin"),
onFailureRedirection: () => "/not-an-admin"
},
]
}
>
{props.children}
</SessionAuth>
);
}

Showing an access denied screen#

Setting an AccessDeniedScreen#

By default, the SDK will still render the children passed to SessionAuth even if a claim validation has failed. However, you can add an access denied screen by passing a component as accessDeniedScreen in the SessionAuth props. This component will be rendered instead of children if a session claim validator has failed (see below on how you can control this on the validator level).

import React from "react";
import { SessionAuth, useSessionContext } from 'supertokens-auth-react/recipe/session';
import { AccessDeniedScreen } from 'supertokens-auth-react/recipe/session/prebuiltui';
import { UserRoleClaim, /*PermissionClaim*/ } from 'supertokens-auth-react/recipe/userroles';

const AdminRoute = (props: React.PropsWithChildren<any>) => {
return (
<SessionAuth
overrideGlobalClaimValidators={(globalValidators) =>
[...globalValidators,
UserRoleClaim.validators.includes("admin"),
]
}
accessDeniedScreen={AccessDeniedScreen}
>
{props.children}
</SessionAuth>
);
}

In the above code, we pass the AccessDeniedScreen from our SDK, but you can make your own component as well.

Disabling the access denied screen for a specific validator#

You can disable the access denied screen on a per-validator basis. If showAccessDeniedOnFailure is set to false on the validator, SessionAuth will render the children instead of the access denied screen even if the validator fails. In this case, you can handle the invalid claims by checking the invalidClaims prop in the session context.

This is useful in case you have several validators that are running and you want to make an exception for one of them to not show the access denied screen, but still show it for the other failures.

note

We may still shown the access denied screen if another validator fails.

import React from "react";
import { SessionAuth } from 'supertokens-auth-react/recipe/session';
import { UserRoleClaim } from 'supertokens-auth-react/recipe/userroles';
import { AccessDeniedScreen } from 'supertokens-auth-react/recipe/session/prebuiltui';

const AdminRoute = (props: React.PropsWithChildren<any>) => {
return (
<SessionAuth
overrideGlobalClaimValidators={(globalValidators) =>
[
...globalValidators,
{
...UserRoleClaim.validators.includes("admin"),
showAccessDeniedOnFailure: false,
},
]
}
accessDeniedScreen={AccessDeniedScreen}
>
{props.children}
</SessionAuth>
);
}

In the session context#

If a claim validation failure required neither redirection nor showing the access denied screen, you can handle failing claim validators in the children of the SessionAuth using the invalidClaims prop of the session context (accessed through useSessionContext):

import Session, { BooleanClaim } from "supertokens-auth-react/recipe/session";

const SecondFactorClaim = new BooleanClaim({
id: "2fa-completed",
refresh: async () => {
// we do nothing here because refreshing the 2fa claim doesn't make sense
},
showAccessDeniedOnFailure: false
});

const Dashboard = () => {
let sessionContext = Session.useSessionContext();

if (sessionContext.loading) {
return null;
}

if (sessionContext.invalidClaims.some(i => i.validatorId === SecondFactorClaim.id)) {
// the 2fa check failed. We should redirect the user to the second
// factor screen.
return "You cannot access this page because you have not completed 2FA";
}

// 2FA check passed
}