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:
- Load the customizer record for the specific token type (access token or client credentials)
- Build a context sample containing user, grant, interaction, application, and organization data
- Execute the script in a sandbox VM with a
CustomJwtScriptPayloadobject:
{
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
}
}
- Merge the returned token payload with the original claims
- 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
CustomJwtScriptPayloadcontainingtoken,context(user/app/org data),environmentVariables, and anapiobject withdenyAccess(). - Context data is validated by specific guards including
jwtCustomizerUserContextGuardandjwtCustomizerApplicationContextGuardto ensure security. - Store customizers via the admin API using keys
jwt.accessTokenorjwt.clientCredentialswith payloads matchingaccessTokenJwtCustomizerGuard. - Test customizers safely using the
/api/jwt/customizer/testendpoint 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →