JWTs carry claims — key-value pairs in the payload. RFC 7519 defines a set of registered claims (sub, iss, exp, iat, etc.) that have well-understood semantics. Everything else is a custom claim, and how you define and evolve those custom claims determines whether your auth system becomes a maintenance burden or stays clean over time. This post covers the rules for custom claim design that scale.
Registered vs public vs private claims
RFC 7519 defines three classes of claims:
- Registered claims — standardized names with defined semantics:
iss,sub,aud,exp,nbf,iat,jti. Always use these correctly — misusingsubas a username string instead of a unique identifier breaks interoperability. - Public claims — registered with IANA or using a collision-resistant namespace (typically a URL). Examples:
email,name,rolesfrom the OIDC Core spec. - Private claims — custom claims used between a specific producer and consumer. Must be namespaced to avoid collision.
Namespacing: why it matters
Imagine you add a claim called role to your access tokens. A downstream library update adds its own handling for a role claim with different semantics. Or a new IANA registration creates a standard role claim that conflicts with yours. This is claim shadowing — your custom claim collides with a standard or future standard definition.
The solution is to namespace all custom claims with a URL you control:
// Bad: unnamespaced custom claims
const badPayload = {
sub: 'user_123',
role: 'admin', // collision risk
org: 'org_456', // collision risk
plan: 'enterprise', // collision risk
};
// Good: namespaced custom claims (Auth0/Okta convention)
const goodPayload = {
sub: 'user_123',
'https://bastionary.com/role': 'admin',
'https://bastionary.com/org_id': 'org_456',
'https://bastionary.com/plan': 'enterprise',
};
// Alternative: nested namespace object (cleaner but some parsers don't support it well)
const nestedPayload = {
sub: 'user_123',
'https://bastionary.com/claims': {
role: 'admin',
org_id: 'org_456',
plan: 'enterprise',
},
};
The URL doesn't need to resolve to anything — it's used purely as a unique namespace identifier. Using your domain ensures global uniqueness.
Accessing namespaced claims in code
Bracket notation in JavaScript handles URL-format claim names cleanly. Define typed accessors to avoid repeating the namespace string:
const CLAIMS_NS = 'https://bastionary.com';
interface BastionaryTokenPayload {
sub: string;
iss: string;
aud: string | string[];
exp: number;
iat: number;
[`${typeof CLAIMS_NS}/role`]: string;
[`${typeof CLAIMS_NS}/org_id`]: string;
[`${typeof CLAIMS_NS}/plan`]: 'free' | 'pro' | 'enterprise';
[`${typeof CLAIMS_NS}/mfa_verified`]: boolean;
}
// Typed accessor helper
function getClaim<T>(payload: Record<string, unknown>, claim: string): T {
return payload[`${CLAIMS_NS}/${claim}`] as T;
}
// Usage:
const role = getClaim<string>(tokenPayload, 'role');
const orgId = getClaim<string>(tokenPayload, 'org_id');
What to put in the token vs what to fetch
Custom claims are tempting as a way to avoid database lookups. Need the user's permissions? Put them in the token. Need their org's plan? In the token. This works until you realize that:
- Claims in the token are valid until the token expires, even if the data changed. A user demoted from admin still has an
adminclaim until their access token rotates. - Tokens are transmitted on every request. Adding a 500-byte permissions array inflates every API call's HTTP overhead.
- JWTs are base64-encoded, not encrypted by default. Sensitive business data in claims is readable to anyone with the token.
A practical rule: put in the token only what's needed to make authorization decisions without a database lookup for the majority of API calls. For everything else, use the token's sub as a key to load the authoritative data:
// In the token: identity + coarse access tier (stable, low-latency needed)
const tokenClaims = {
sub: 'user_123',
[`${CLAIMS_NS}/org_id`]: 'org_456',
[`${CLAIMS_NS}/plan`]: 'enterprise', // rarely changes
[`${CLAIMS_NS}/mfa_verified`]: true, // set at login time
};
// NOT in the token: fine-grained permissions (changes frequently)
// Load these from Redis/DB on each request using sub + org_id as cache key
async function getPermissions(userId: string, orgId: string, cache: Redis) {
const cacheKey = `perms:${orgId}:${userId}`;
const cached = await cache.get(cacheKey);
if (cached) return JSON.parse(cached);
const perms = await db.permissions.findForUser(userId, orgId);
await cache.setex(cacheKey, 60, JSON.stringify(perms)); // 60-second cache
return perms;
}
Token size considerations
A typical JWT with standard claims runs 200–300 bytes. Cookie size limits are 4KB; some load balancers have issues with headers over 8KB. Once you start adding arrays of permissions or roles, tokens grow quickly. Measure:
function measureTokenSize(token: string): void {
const bytes = Buffer.byteLength(token, 'utf8');
console.log(`Token size: ${bytes} bytes`);
if (bytes > 1500) {
console.warn('Token approaching cookie size limits — consider moving claims to DB');
}
}
// Decode without verification to inspect size
function decodeTokenPayload(token: string): object {
const parts = token.split('.');
return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
}
Evolving claims: the compatibility contract
Adding a new claim is safe — consumers that don't know about it will ignore it. Removing or renaming a claim is a breaking change for any consumer that depends on it.
Deprecation process:
- Add the new claim name alongside the old one in issued tokens
- Document the deprecation with a timeline
- After all consumers have migrated (verify with token analytics or API version headers), remove the old claim from new token issuances
- Old tokens with the old claim name will naturally expire
// During transition: emit both old and new claim names
const transitionPayload = {
sub: userId,
// Old: unnamespaced (deprecated, remove after 2025-06-01)
role: userRole,
// New: namespaced (canonical)
[`${CLAIMS_NS}/role`]: userRole,
};