Add custom claims for JWT access tokens with Logto to boost your authorization

In this article, we will introduce how to use Logto custom JWT claims feature to improve the flexibility of authorization and the performance of the service provider through a real-world example.
Darcy Ye
Darcy YeDeveloper
April 24, 202414 min read
Add custom claims for JWT access tokens with Logto to boost your authorization

In previous articles, we mentioned that more and more systems are using JWT-format access tokens for user authentication and access control. One of the important reasons for this is that JWT can contain some useful information, such as user roles and permissions. This information can help us pass user identity information between the server and client, thereby achieving user authentication and access control.

Usually, the information contained in JWT is determined by the authentication server. According to the OAuth 2.0 protocol, JWT usually contains fields such as sub (subject), aud (audience), and exp (expiration time), which are commonly referred to as claims. These claims can help verify the validity of the access token.

However, there are countless scenarios where JWT is used for verification, and common JWT claims may often not meet user needs. People often think that since JWT can contain some information, can we add some information to it to make authorization easier?

The answer is YES, we can add custom claims to JWT, such as the current user's scope and subscription level. In this way, we can pass user identity information between the client and the server (here refers to the server that provides various different services, also called service provider), to achieve user authentication and access control.

For standard JWT claims, please refer to RFC7519. Logto, as an identity solution that supports both authentication and authorization, has extended the resource and scope claims on this basis to support standard RBAC. Although Logto's RBAC implementation is standard, it is not simple and flexible enough to fit all use cases.

Based on this, Logto launched a new feature of customizing JWT claims, which allows users to customize additional JWT claims, so that user authentication and access control can be implemented more flexibly.

How does Logto custom JWT claims work?

You can come to the Custom JWT listing page by clicking on the "JWT claims" button on the sidebar.

custom-jwt-listing-page

Let's start from adding custom claims for end-users.

In the editor on the left, you can customize your getCustomJwtClaims function. This method has three input parameters: token, data, and envVariables.

  • token is the raw access token payload obtained based on the current end-user's credentials and your system configuration, and the user's access-related information in Logto
  • data is all the information about the user in Logto, including all the user's roles, social sign-in identities, SSO identities, organization memberships, etc.
  • envVariables are the environment variables you configured in Logto for the current end-user access token usage scenario, such as API key(s) of the required external APIs, etc.
details-page-user-data

The cards on the right can be expanded to show the introduction of the corresponding parameters, and you also can set the environment variables for the current scenario here.

details-page-user-test

After reading the introductions of all the cards on the right, you can switch to the test mode, where you can edit test data and use the edited test data to check whether the behavior of the script you wrote in the code editor on the left meets your expectations.

This is a sequence diagram showing the execution process of the getCustomJwtClaims function when an end user initiates an authentication request to Logto and eventually obtains the JWT-format access token returned by Logto.

If the Custom JWT feature is not enabled, step 3 in the figure will be skipped and step 4 will be executed right after step 2 ends. At this time, Logto will assume the return value of getCustomJwtClaims to be an empty object, and then continue to go through subsequent steps.

Service ProviderLogto (identity provider)User or user agentService ProviderLogto (identity provider)User or user agentCustom JWTpar[Get service via API]Auth request (with credentials)1Validate credentials &generate raw access token payload2Run custom JWT claims script (`getCustomJwtClaims`) &get extra JWT claims3Merge raw access token payload and extra JWT claims4Sign & encrypt payload to get JWT access token5Issue JWT-format access token6service request (with JWT access token)7service response8

For security reasons, if the JWT claims returned by Logto's getCustomJwtClaims function already exist in the access token payload, these claims WILL NOT take effect and they WILL NOT be able to overwrite the existing claims.

Boost your authorization with custom JWT claims: a practical example

In the previous section, we introduced the working principle of the Logto custom JWT. In this part, we will show you how to use Logto custom JWT claims to improve the flexibility of authorization and the performance of the service provider through a real-world example.

Scenario setup

John's team developed an AI Assistant App that allows users to converse with AI robots to obtain various services.

The AI robot services are divided into free and paid services. Free services include special airfare recommendations, while paid services include stock predictions.

The AI Assistant App uses Logto to manage all users, which are divided into three types: free users, pre-paid users, and premium users. Free users can only use free services, pre-paid users can use all services (charged by usage), and premium users can use all services (but have rate limits to prevent malicious use).

In addition, the AI Assistant App uses Stripe to manage user payments and has its own log service to record user operation logs.

Logto configurations

We first create API resources for the AI Assistant App service and create two scopes, recommend:flight and predict:stock.

ai-assistant-app-resource

Then we create two roles, free-user and paid-user, and assign the corresponding scopes:

  • Assign the recommend:flight scope to the free-user role.
  • Assign both recommend:flight and predict:stock scopes to the paid-user role.
free-user-role
paid-user-role

Finally, we create three users, free-user, prepaid-user, and premium-user, and assign the corresponding roles:

  • Assign the free-user role to user free-user.
  • Assign the paid-user role to users prepaid-user and premium-user.
assign-free-user-role
assign-paid-user-role

As shown in the following figure, in order to implement the authorization information required for the scenario described above, we hope to include the roles, balance and numOfCallsToday information of the currently logged-in user in the JWT. When verifying the access token in the AI Assistant App, these information can be used to quickly perform permission verification.

test-custom-jwt-claims

After configuring the envVariables, we implement the getCustomJwtClaims function and click the "Run test" button to see the result of the extra JWT claims based on the current test data.

Since we have not configured the test data for data.user.roles , the roles shown in the result is an empty array.

Check whether the custom JWT feature kicks in

According to the Logto configuration above, we got the corresponding results in the test. Next, we will use the sample app provided by Logto to verify whether our custom JWT is effective. Find the SDK you are familiar with at Logto SDKs and deploy a sample app according to the documentation and the corresponding GitHub repo.

Based on the configuration we described above, taking the React SDK as an example, we need to update the corresponding configuration in LogtoConfig:

import { LogtoProvider, LogtoConfig, UserScope } from '@logto/react';

const config: LogtoConfig = {
    appId,
    endpoint,
    scopes: [
      UserScope.Email,
      UserScope.Phone,
      UserScope.CustomData,
      UserScope.Identities,
      UserScope.Organizations,
      "recommend:flight",
      "predict:stock",
    ],
    resources: ["https://api.aiassistant.app/v1"]
};

const App = () => (
  <LogtoProvider config={config}>
    <YourAppContent />
  </LogtoProvider>
);

After signing in user free_user to the sample app that simulates the AI Assistant App, we can see the roles, balance, numOfCallsToday, isPaidUser and isPremiumUser information we added by viewing the payload part of the JWT access token.

sample-app-access-token-preview-free

The values of balance, numOfCallsToday, isPaidUser and isPremiumUser are consistent with previous test, and roles equals ["free-user"]. This is because in the actual end-user login process, we will obtain all the accessible data of the user and process it accordingly.

sample-app-access-token-preview-premium

For premium users, we can see that roles is ["paid-user"] and both isPaidUser and isPremiumUser are true.

Update service provider authorization logic

In the previous steps, we added custom claims based on business needs to the user access token. Next, we can use these claims to quickly perform authorization verification.

It is important to note that we do not recommend relying entirely on custom claims for authorization verification.

Since the implementation of RBAC follows the principle of least privilege, in order to ensure security, the verification using custom claims should be an additional auxiliary verification based on RBAC. This is to avoid expanding the scope of user authorization permissions due to the introduction of additional custom claims.

Here we provide the logic of Logto verifying JWT access tokens on the API side. The complete code implementation can be found in the GitHub repo:

import type { IncomingHttpHeaders } from 'node:http';

import { createLocalJWKSet, jwtVerify, type JWK } from 'jose';

const extractBearerTokenFromHeaders = (
  { authorization }: IncomingHttpHeaders
) => {
  const bearerTokenIdentifier = 'Bearer';

  assertThat(
    authorization,
    new RequestError({
      code: 'auth.authorization_header_missing',
      status: 401
    })
  );
  assertThat(
    authorization.startsWith(bearerTokenIdentifier),
    new RequestError(
      { code: 'auth.authorization_token_type_not_supported', status: 401 },
      { supportedTypes: [bearerTokenIdentifier] }
    )
  );

  return authorization.slice(bearerTokenIdentifier.length + 1);
};

const verifyBearerTokenFromRequest = async (
  publicJwks: JWT;
  issuer: string,
  request: Request,
  audience: Optional<string>
): Promise<TokenInfo> => {
  try {
    // Get the public JWKs and issuer.
    const [keys, issuer] = await getKeysAndIssuer();
    const { payload } = await jwtVerify(
      extractBearerTokenFromHeaders(request.headers),
      createLocalJWKSet({ keys }),
      {
        issuer,
        audience,
      }
    );

    const { sub, client_id: clientId, scope = '' } = payload;
    assertThat(sub,
      new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }));

    return { sub, clientId, scopes: z.string().parse(scope).split(' ') };
  } catch (error: unknown) {
    if (error instanceof RequestError) {
      throw error;
    }

    throw new RequestError(
      { code: 'auth.unauthorized', status: 401 },
      error
    );
  }
};

You can refer to the logic of Logto API for verifying access tokens and customize it according to your own business logic. For example, for the AI Assistant App scenario described here, you can add verification logic for custom claims such as roles, balance, numOfCallsToday, isPaidUser and isPremiumUser in the verifyBearerTokenFromRequest function.

const verifyBearerTokenFromRequest = async (
  publicJwks: JWT;
  issuer: string,
  request: Request,
  audience: Optional<string>
): Promise<TokenInfo> => {
  try {
    // Get the public JWKs and issuer.
    const [keys, issuer] = await getKeysAndIssuer();
    const { payload } = await jwtVerify(
      extractBearerTokenFromHeaders(request.headers),
      createLocalJWKSet({ keys }),
      {
        issuer,
        audience,
      }
    );

    const {
      sub,
      client_id: clientId,
      roles,
      balance,
      numOfCallsToday,
      isPaidUser,
      isPremiumUser,
      scope = ''
    } = payload;
    assertThat(sub,
      new RequestError({ code: 'auth.jwt_sub_missing', status: 401 }));

    return {
      sub,
      clientId,
      scopes: z.string().parse(scope).split(' '),
      roles,
      balance,
      numOfCallsToday,
      isPaidUser,
      isPremiumUser
    };
  } catch (error: unknown) {
    if (error instanceof RequestError) {
      throw error;
    }

    throw new RequestError({ code: 'auth.unauthorized', status: 401 }, error);
  }
};

const authorizeBearerTokenPayload = (
  payload: Payload,
  requiredScopes: string[],
  requiredRoles: string[]
): void => {
  const {
    scope,
    roles,
    balance,
    numOfCallsToday,
    isPaidUser,
    isPremiumUser
  } = payload;
  const scopes: string[] = scope.split(' ');

  // RBAC validation.
  /**
   * `free-user` does not have the `predict:stock` scope, so if the API requires
   * the `predict:stock` scope, the request will be rejected directly.
   */
  assertThat(
    requiredScopes.every((scope) => payload.scopes.includes(scope)),
    new RequestError(
      { code: 'auth.insufficient_scope', status: 403 },
      { requiredScopes })
  );

  /** Validation on extra claims in the `payload`. */
  assertThat(
    roles.includes('paid-user') && isPaidUser,
    new RequestError({ code: 'auth.role_mismatch', status: 403 }));
  // Supporse the daily call limit is 100.
  assertThat(
    numOfCallsToday < 100,
    new RequestError({ code: 'auth.rate_limit_exceeded', status: 403 }));
  // No need to check the `balance` of premium users.
  if (isPremiumUser) {
    return;
  }
  // Can access the service only when the balance is positive.
  assertThat(
    balance > 0,
    new RequestError({ code: 'auth.balance_insufficient', status: 403 }));
  /** Validation on extra claims in the `payload`. */
};

The above example is for the scenario where it affects the end-user login and obtaining the JWT access token. If your use case is machine-to-machine (M2M), you can also configure custom JWT claims for M2M apps separately.

Configuring custom JWT for users will not affect the result of M2M apps obtaining access tokens, and vice versa.

Due to the generality of M2M connections, Logto does not currently provide the function of the getCustomJwtClaims method of M2M apps to accept internal data from Logto. In other aspects, the configuration method of custom JWT for M2M apps is the same as that of user apps. This article will not elaborate on it. You can use Logto's custom JWT function to get started.

Why use custom JWT claims?

We have provided the AI Assistant App scenario for John and how to use Logto's Custom JWT feature to achieve more flexible authorization verification. In this process, we can see the advantages of the Custom JWT feature:

  1. Without the Custom JWT feature, users need to request an external API (such as what you do in getCustomJwtClaims) every time they check permissions. For the service provider that provides this API, this may increase the extra burden. With the Custom JWT feature, these information can be put directly into the JWT, reducing the frequent calls to the external API.
  2. For service providers, the Custom JWT feature can help them verify user permissions faster, especially when the client calls the service provider frequently, improving service performance.
  3. The Custom JWT feature can help you quickly implement additional authorization information required by the business, and the information can be passed between the client and the service provider in a secure manner since JWT is self-contained and could be envrypted so that it is hard to be forged.

It is important to note that, as we mentioned in a previous blog post, once a JWT access token is issued, it will not change during its validity period. Therefore, the information contained in the custom JWT claims should be combined with actual business needs and avoid including information that is important for authorization but changes drastically during the validity period of the access token.

At the same time, since getCustomJwtClaims is executed every time a user needs Logto to issue an access token, it is necessary to avoid executing overly complex logic and external API requests with high bandwidth requirements. Otherwise, it may take too long for end-users to wait for the result of getCustomJwtClaims during the sign-in process. If your getCustomJwtClaims returns an empty object, we strongly recommend that you temporarily delete this configuration item until you actually need to use it.

Conclusion

In this article, Logto has extended the basic JWT access token and extended the function of extra JWT claims to allow users to put additional end-user information into the JWT access token according to their business needs, so that after the user logs in, the user's permissions can be quickly verified.

We provide the scenario of John's AI Assistant App and demonstrate how to use Logto's Custom JWT feature to achieve more flexible authorization verification. We also point out some key points of using custom JWT. In combination with actual business scenarios, users can put various user-related information into the JWT access token according to their business needs, so that the service provider can quickly verify the user's permissions.