Custom Claims
Add application-specific data to your JWT tokens using custom claims.
Overview
Custom claims allow you to enrich JWT tokens with additional user data beyond the standard OAuth profile. Claims can be static values or dynamic functions that fetch data from databases or external APIs.
Nuxt Aegis supports custom claims at two levels:
- Provider-level claims: Specific to each OAuth provider (e.g., Google, GitHub)
- Handler-level claims: Global fallback that applies to all providers
Priority
Provider-level claims take precedence over handler-level claims when both are defined.
Provider-Level Claims
Static Custom Claims
Add fixed values to all tokens for a specific provider:
// server/routes/auth/google.get.ts
export default defineOAuthGoogleEventHandler({
customClaims: {
role: 'admin',
department: 'engineering',
accountType: 'premium',
},
})All tokens issued after Google authentication will include these claims:
{
"sub": "google:123456789",
"email": "user@example.com",
"name": "John Doe",
"role": "admin",
"department": "engineering",
"accountType": "premium"
}Dynamic Custom Claims
Use a callback function to compute claims dynamically:
// server/routes/auth/google.get.ts
export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens) => {
return {
role: user.email?.endsWith('@admin.com') ? 'admin' : 'user',
permissions: getUserPermissions(user.email),
emailVerified: user.email_verified || false,
loginCount: await getLoginCount(user.sub),
}
},
})Dynamic Claims
The callback receives two arguments:
user: User information from the OAuth providertokens: OAuth tokens (access_token,refresh_token,id_token,expires_in)
Database Lookups
Fetch user data from your database:
// server/routes/auth/google.get.ts
export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens) => {
// Fetch user profile from database
const userProfile = await db.getUserProfile(user.email)
return {
role: userProfile.role,
permissions: userProfile.permissions,
organizationId: userProfile.organizationId,
subscription: userProfile.subscription,
}
},
})export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens) => {
const profile = await prisma.user.findUnique({
where: { email: user.email },
include: { organization: true },
})
return {
role: profile?.role || 'user',
organizationId: profile?.organization?.id,
tier: profile?.tier || 'free',
}
},
})export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens) => {
const [profile] = await db
.select()
.from(users)
.where(eq(users.email, user.email))
.limit(1)
return {
role: profile?.role || 'user',
departmentId: profile?.departmentId,
isActive: profile?.isActive || false,
}
},
})Using Provider Tokens
Access the OAuth provider's access token to fetch additional data:
// server/routes/auth/google.get.ts
export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens) => {
// Fetch additional data from Google using their access token
const extraData = await $fetch('https://people.googleapis.com/v1/people/me?personFields=phoneNumbers', {
headers: {
Authorization: `Bearer ${tokens.access_token}`
}
})
return {
role: determineRole(user),
phoneNumber: extraData.phoneNumbers?.[0]?.value,
verified: user.email_verified,
}
},
})Token Refresh
During token refresh, the custom claims callback is invoked again with the stored user object. Provider tokens are NOT available during refresh since we're not re-authenticating with the provider.
Handler-Level Claims
Handler-level claims provide a global function that applies to all authentication methods when provider-level claims are not defined. This is particularly useful for:
- Centralizing logic across multiple OAuth providers
- Supporting password authentication (which doesn't have provider-level customClaims)
- Implementing consistent permission models
// server/plugins/aegis.ts
export default defineNitroPlugin(() => {
defineAegisHandler({
// Global custom claims handler
customClaims: async (user) => {
// Fetch from database based on user ID
const dbUser = await db.getUserById(user.userId)
return {
role: dbUser.role,
permissions: dbUser.permissions,
organizationId: dbUser.organizationId,
}
},
onUserPersist: async (user, { provider }) => {
// Persist user to database...
return { userId: dbUser.id }
},
})
})Priority Rules
When both provider-level and handler-level claims are defined:
// server/plugins/aegis.ts
defineAegisHandler({
customClaims: async (user) => {
return {
role: 'user', // ← Will be overridden by provider
tier: 'free', // ← Will be used (not in provider claims)
}
},
})
// server/routes/auth/google.get.ts
export default defineOAuthGoogleEventHandler({
customClaims: {
role: 'admin', // ← Takes priority
},
})
// Result: { role: 'admin', tier: 'free' }Best Practice
Use handler-level claims for shared authorization logic and provider-level claims for provider-specific attributes.
Supported Claim Types
Custom claims support the following types:
| Type | Example | Supported |
|---|---|---|
string | 'admin' | ✅ |
number | 42 | ✅ |
boolean | true | ✅ |
null | null | ✅ |
Array<primitive> | ['read', 'write'] | ✅ |
object | { nested: 'value' } | ❌ |
function | () => {} | ❌ |
undefined | undefined | ❌ |
Nested Objects Not Allowed
Custom claims cannot contain nested objects or functions. Only primitive values and arrays of primitives are supported.
Reserved Claims
The following JWT claims are reserved and cannot be overridden:
iss- Issuersub- Subject (user ID)exp- Expiration timeiat- Issued atnbf- Not beforejti- JWT IDaud- Audience
If you attempt to override these, they will be filtered out and a warning will be logged.
export default defineOAuthGoogleEventHandler({
customClaims: {
role: 'admin',
exp: 9999999999, // ❌ Will be filtered out and warning logged
sub: 'custom-id', // ❌ Will be filtered out and warning logged
},
})Common Use Cases
Role-Based Access Control (RBAC)
export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens) => {
// Determine role based on email domain
let role = 'user'
if (user.email?.endsWith('@company.com')) {
role = user.email.endsWith('@admin.company.com') ? 'admin' : 'employee'
}
return { role }
},
})Access in your app:
// Server-side
export default defineEventHandler(async (event) => {
const user = await requireAuth(event)
if (user.role !== 'admin') {
throw createError({ statusCode: 403, message: 'Admin only' })
}
return { data: 'sensitive' }
})
// Client-side
const { user } = useAuth()
const isAdmin = computed(() => user.value?.role === 'admin')Permissions Array
export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens) => {
const permissions = await db.getUserPermissions(user.email)
return {
permissions: permissions.map(p => p.name), // ['posts:create', 'posts:delete']
}
},
})Check permissions:
// server/utils/requirePermission.ts
export async function requirePermission(event: H3Event, permission: string) {
const user = await requireAuth(event)
if (!user.permissions?.includes(permission)) {
throw createError({ statusCode: 403, message: 'Insufficient permissions' })
}
return user
}Organization/Tenant ID
export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens) => {
const org = await db.getOrganizationByEmail(user.email)
return {
organizationId: org.id,
organizationName: org.name,
tier: org.tier, // 'free', 'pro', 'enterprise'
}
},
})Feature Flags
export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens) => {
const features = await getFeatureFlagsForUser(user.email)
return {
features: features.filter(f => f.enabled).map(f => f.name),
betaAccess: features.some(f => f.name === 'beta'),
}
},
})Subscription Status
export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens) => {
const subscription = await db.getSubscription(user.email)
return {
premium: subscription?.status === 'active',
plan: subscription?.plan || 'free',
expiresAt: subscription?.expiresAt?.toISOString(),
}
},
})Type Safety
Define custom claim types for full type safety:
// types/auth.ts
export interface CustomClaims {
role: 'admin' | 'user' | 'guest'
permissions: string[]
organizationId: string
premium: boolean
}
// server/routes/auth/google.get.ts
export default defineOAuthGoogleEventHandler({
customClaims: async (user, tokens): Promise<CustomClaims> => {
// TypeScript will enforce return type
return {
role: 'user',
permissions: [],
organizationId: '123',
premium: false,
}
},
})
// Client-side usage
const { user } = useAuth<User & CustomClaims>()
const role = user.value?.role // Type: 'admin' | 'user' | 'guest'Testing Custom Claims
Test custom claims in your test suite:
import { describe, it, expect } from 'vitest'
import { $fetch } from '@nuxt/test-utils'
describe('Custom Claims', () => {
it('should assign role based on email domain', async () => {
const token = await authenticateAs('admin@company.com')
const payload = decodeJWT(token)
expect(payload.role).toBe('admin')
})
it('should include organization ID', async () => {
const token = await authenticateAs('user@company.com')
const payload = decodeJWT(token)
expect(payload.organizationId).toBeDefined()
})
})Best Practices
Recommendations
- Keep claims small - JWT size affects every request
- Use primitives - Avoid nested objects
- Cache database lookups - Don't query on every token issue
- Validate claim values - Ensure data types are correct
- Document your claims - Maintain a list of custom claims
- Version your claims - Add version field if structure changes
- Consider refresh behavior - Claims are re-computed on refresh
Common Pitfalls
- Don't store large amounts of data in claims
- Don't include sensitive secrets in claims
- Don't rely on claims for real-time data (they're cached until refresh)
- Don't forget that claims are visible to the client