A freshly issued JWT from an OIDC provider contains standard claims: sub, email, name, iat, exp. That's what the spec requires. But your application needs more: the user's org role, their plan tier, their feature flags, whether they've completed onboarding. You could fetch these from the database on every API request — or you could embed them in the token at issuance time through claims transformation.
Claims transformation is the practice of intercepting the token issuance process and adding application-specific claims before the JWT is signed and returned to the client. Modern auth platforms implement this via pre-token hooks — webhook calls or scriptable functions that run just before a token is minted.
The pre-token hook pattern
When your authorization server is about to issue a token, it calls your hook with information about the user and grant. Your hook returns additional claims, which the authorization server merges into the token payload before signing.
// Bastionary / Auth0 / Okta pre-token hook endpoint
// POST /hooks/pre-token
export async function preTokenHook(req: Request, res: Response) {
const { userId, clientId, scopes, grantType } = req.body;
// Validate the request came from your auth server
const secret = req.headers['x-hook-secret'];
if (secret !== process.env.HOOK_SECRET) {
return res.status(401).json({ error: 'Unauthorized' });
}
// Fetch user's org membership and roles
const memberships = await db.orgMembers.findByUserId(userId, {
include: ['org', 'role'],
});
// Fetch subscription tier
const subscription = await db.subscriptions.findActiveByUserId(userId);
const additionalClaims: Record<string, any> = {
// Org roles as an array of {org_id, role} objects
'https://app.example.com/org_roles': memberships.map(m => ({
org_id: m.orgId,
role: m.role,
})),
// Plan tier — affects feature availability
'https://app.example.com/plan': subscription?.tier ?? 'free',
// App-level role (platform admin vs regular user)
'https://app.example.com/app_role': await getAppRole(userId),
};
res.json({ additionalClaims });
}
Note the namespaced claim keys (https://app.example.com/org_roles). Custom claims in JWTs must use URL-format namespaces to avoid collision with standard claim names. This is an OIDC requirement, not optional.
Avoiding token bloat
The temptation with claims transformation is to embed everything: roles, permissions, feature flags, user preferences, the last 10 actions. Resist this. JWTs are transmitted on every HTTP request, stored in cookies, decoded in browser memory. A 10KB JWT is not a performance problem in isolation — but multiply that by 50 API calls per page load across 10,000 concurrent users and you've added 5GB of header data per minute.
Rules for what belongs in a token:
- Include: Claims that are needed on almost every request — user ID, org ID, plan tier, app-level role. Low cardinality, high frequency of use.
- Exclude: Detailed permissions lists, feature flags, user preferences, anything that changes frequently relative to token TTL.
- Measure: Keep your access token payload under 2KB after base64 encoding. Decode some real tokens in production to check.
// Checking encoded token size before signing
function validateClaimsSize(claims: Record<string, any>): void {
const payloadJson = JSON.stringify(claims);
const base64Encoded = Buffer.from(payloadJson).toString('base64');
if (base64Encoded.length > 2048) {
throw new Error(
`Token payload too large: ${base64Encoded.length} bytes. ` +
`Consider lazy-loading permissions instead of embedding them.`
);
}
}
Caching in the hook
Your pre-token hook runs on every token issuance — login, refresh, and silent re-authentication. If the hook makes database calls, those calls are on the critical path of authentication. Cache aggressively.
const claimsCache = new Map<string, { claims: any; expiresAt: number }>();
async function getCachedUserClaims(userId: string): Promise<Record<string, any>> {
const cacheKey = userId;
const cached = claimsCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.claims;
}
// Cache miss: fetch from DB
const claims = await fetchUserClaims(userId);
// Cache for 60 seconds — short enough that role changes propagate quickly
claimsCache.set(cacheKey, {
claims,
expiresAt: Date.now() + 60_000,
});
return claims;
}
For production, use Redis instead of an in-process Map to share the cache across hook instances. Set the TTL to match your tolerance for stale roles — typically 30-120 seconds is acceptable. A user who has their admin role revoked may continue to have it in their token for up to one TTL interval; whether this is acceptable depends on your security requirements.
Lazy loading permissions
For fine-grained permissions that are too detailed to embed in a token, use lazy loading: the token carries the user's org ID and role, and the API fetches the full permission set from a cache when it needs to make a detailed authorization decision.
// API middleware: expand token claims to full permissions on demand
async function expandPermissions(token: JwtPayload): Promise<UserContext> {
const orgRoles = token['https://app.example.com/org_roles'] as OrgRole[];
// Only fetch permissions if this request needs detailed authorization
// Most requests only need org_id and role — skip the expansion
const permissions = await permissionsCache.getOrFetch(
`perms:${token.sub}:${orgRoles.map(r => r.org_id).join(',')}`,
() => db.permissions.getForUser(token.sub, orgRoles),
{ ttl: 30 }
);
return { userId: token.sub, orgRoles, permissions };
}
This pattern gives you the best of both worlds: fast token validation (small JWT, no database lookup for common requests) and detailed authorization (full permissions available when needed, cached to avoid per-request DB queries).