How to Customize JWT Tokens Using the JWT Customizer in Logto

You can customize JWT tokens in Logto by writing a JavaScript script that runs in a sandbox during token issuance, allowing you to modify claims, inject user data, or deny access based on custom business logic.

The JWT Customizer in Logto (logto-io/logto) provides a programmatic way to shape the content of issued access tokens and client credentials tokens. Instead of accepting static OIDC claims, you can inject custom data from the user context, application metadata, or external environment variables before the token is signed and returned to the client.

Understanding the JWT Customizer Architecture

The customizer consists of a JavaScript snippet stored in the logto_configs table under keys like jwt.accessToken or jwt.clientCredentials. When Logto issues a token, it executes this script inside a secure sandbox with access to a controlled payload object.

Configuration Schema and Storage

According to the source code in packages/schemas/src/types/logto-config/jwt-customizer.ts, the customizer configuration is validated by jwtCustomizerGuard and related guards. The stored record contains:

  • script: The JavaScript code string that runs during token issuance
  • environmentVariables: Optional key-value pairs accessible via process.env
  • sample context: Data shape definitions for testing

The runtime validation uses customJwtFetcherGuard to ensure that requests from the admin UI meet the expected schema before execution.

Runtime Execution Flow

During token issuance, Logto performs the following steps:

  1. Load the customizer record for the specific token type (access token or client credentials)
  2. Build a context sample containing user, grant, interaction, application, and organization data
  3. Execute the script in a sandbox VM with a CustomJwtScriptPayload object:
{
  token: Record<string, unknown>,           // Standard OIDC claims
  context?: Record<string, unknown>,       // User, app, org data
  environmentVariables?: Record<string, string>,
  api: {
    denyAccess: (message?: string) => never // Abort token issuance
  }
}
  1. Merge the returned token payload with the original claims
  2. Return the final JWT, or an OIDC error if the script throws a CustomJwtErrorCode

Accessing Context Data in Your Script

The script receives rich context data through various guards defined in the schema file. These guards ensure only serializable, non-sensitive data reaches the sandbox.

User Context

The jwtCustomizerUserContextGuard provides standard user fields (sub, email) plus additional metadata:

  • Password status and SSO identities
  • MFA factors (without secrets)
  • Assigned roles and organizations

Grant and Interaction Context

For token exchange flows, jwtCustomizerGrantContextGuard contains the original subject token payload. The jwtCustomizerUserInteractionContextGuard captures interaction events, verification records (password, email code, MFA), and the user ID associated with the authentication event.

Application and Organization Context

jwtCustomizerApplicationContextGuard exposes all application fields except secrets, while jwtCustomizerOrganizationContextGuard provides minimal organization data for organization-scoped tokens.

Writing a JWT Customizer Script

Your script must export a function that accepts the payload object and optionally returns the modified token. Since the VM mutates the object in-place, you can modify payload.token directly.

// Example: Add admin claim and enforce phone verification
module.exports = (payload) => {
  const { token, context, api } = payload;

  // Add custom claim based on user role
  if (context?.user?.roles?.some(r => r.name === 'admin')) {
    token.isAdmin = true;
    token.permissions = ['read', 'write', 'delete'];
  }

  // Deny access if phone not verified
  if (!context?.user?.phone || !context.user.phoneVerified) {
    api.denyAccess('Phone number verification required');
  }

  // Use environment variables
  if (process.env.CUSTOM_FLAG === 'true') {
    token.customFlag = true;
  }

  // Return optional (modification happens in-place)
  return token;
};

Note that you cannot import external modules; you may only use standard JavaScript features and the provided payload object.

Configuring the Customizer via Admin API

To activate a customizer, POST to the configurations endpoint with a payload matching accessTokenJwtCustomizerGuard (lines 94-108 in the source):

curl -X POST https://localhost:3002/api/configurations \
  -H "Authorization: Bearer <ADMIN_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "jwt.accessToken",
    "value": {
      "script": "module.exports = (payload) => { payload.token.customClaim = \"value\"; };",
      "environmentVariables": {
        "CUSTOM_FLAG": "true",
        "API_VERSION": "v2"
      }
    }
  }'

For client credentials tokens, use the key jwt.clientCredentials instead.

Testing Your Customizer

Logto provides a test endpoint at /api/jwt/customizer/test that validates your script against sample data without affecting live tokens. The request body conforms to jwtCustomizerTestRequestBodyGuard (lines 33-48):

curl -X POST https://localhost:3001/api/jwt/customizer/test \
  -H "Authorization: Bearer <ADMIN_ACCESS_TOKEN>" \
  -H "Content-Type: application/json" \
  -d '{
    "tokenType": "access-token",
    "script": "module.exports = (payload) => { payload.token.foo = \"bar\"; };",
    "token": { "sub": "12345", "aud": "my-client" },
    "context": {
      "user": { 
        "id": "12345", 
        "email": "user@example.com", 
        "emailVerified": true,
        "roles": [{ "name": "admin" }]
      }
    }
  }'

The response contains the modified token with the new foo claim if execution succeeds.

Error Handling and Access Control

You can abort token issuance by calling api.denyAccess() from the CustomJwtApiContext. This triggers an OIDC error response with code access_denied and a body matching customJwtErrorBodyGuard (lines 90-93):

module.exports = (payload) => {
  const { token, context, api } = payload;
  
  // Custom validation logic
  if (context?.user?.roles?.length === 0) {
    api.denyAccess('User must have at least one role assigned');
  }
  
  token.authorized = true;
  return token;
};

When denyAccess is invoked, Logto immediately returns an error to the client without issuing the JWT.

Summary

  • JWT Customizer scripts execute in a sandbox during token issuance, allowing you to modify claims in packages/schemas/src/types/logto-config/jwt-customizer.ts.
  • The script receives a CustomJwtScriptPayload containing token, context (user/app/org data), environmentVariables, and an api object with denyAccess().
  • Context data is validated by specific guards including jwtCustomizerUserContextGuard and jwtCustomizerApplicationContextGuard to ensure security.
  • Store customizers via the admin API using keys jwt.accessToken or jwt.clientCredentials with payloads matching accessTokenJwtCustomizerGuard.
  • Test customizers safely using the /api/jwt/customizer/test endpoint before deploying to production.

Frequently Asked Questions

How do I access user roles inside the JWT customizer script?

The context.user object contains a roles array populated according to jwtCustomizerUserContextGuard. You can check roles using context?.user?.roles?.some(r => r.name === 'admin') and add conditional claims to the token payload based on the user's assigned roles.

Can I import external npm packages in the customizer script?

No, the script runs in a restricted sandbox VM without access to external modules. You can only use standard JavaScript/TypeScript features and the data provided in the payload object, including token, context, and environmentVariables passed during configuration.

What happens if my customizer script throws an error?

If your script throws a CustomJwtErrorCode or calls api.denyAccess(), Logto catches the error and returns an OIDC error response with code access_denied. The error body conforms to customJwtErrorBodyGuard. Other runtime errors may result in a generic token issuance failure.

Where is the JWT customizer configuration stored?

Logto stores the customizer in the logto_configs database table with keys like jwt.accessToken for access tokens or jwt.clientCredentials for client credentials tokens. The record includes the script string and optional environment variables, validated by jwtCustomizerGuard before storage.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →