661 lines
17 KiB
Markdown
661 lines
17 KiB
Markdown
# Clerk JWT Claims: Complete Reference
|
|
|
|
**Last Updated**: 2025-10-22
|
|
**Clerk API Version**: 2025-04-10
|
|
**Status**: Production Ready ✅
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
This guide provides comprehensive documentation for all JWT claims available in Clerk, including default claims, user property shortcodes, organization claims, metadata access patterns, and advanced template features.
|
|
|
|
**Use this guide when**:
|
|
- Creating custom JWT templates in Clerk Dashboard
|
|
- Integrating with third-party services requiring specific token formats
|
|
- Implementing role-based access control (RBAC)
|
|
- Building multi-tenant applications
|
|
- Accessing user data in backend services
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Default Claims (Auto-Included)](#default-claims-auto-included)
|
|
2. [User Property Shortcodes](#user-property-shortcodes)
|
|
3. [Organization Claims](#organization-claims)
|
|
4. [Metadata Access](#metadata-access)
|
|
5. [Advanced Template Features](#advanced-template-features)
|
|
6. [Creating JWT Templates](#creating-jwt-templates)
|
|
7. [TypeScript Type Safety](#typescript-type-safety)
|
|
8. [Common Use Cases](#common-use-cases)
|
|
9. [Limitations & Gotchas](#limitations--gotchas)
|
|
10. [Official Documentation](#official-documentation)
|
|
|
|
---
|
|
|
|
## Default Claims (Auto-Included)
|
|
|
|
Every JWT generated by Clerk automatically includes these claims. **These cannot be overridden in custom templates**.
|
|
|
|
```json
|
|
{
|
|
"azp": "http://localhost:3000", // Authorized party (your app URL)
|
|
"exp": 1639398300, // Expiration time (Unix timestamp)
|
|
"iat": 1639398272, // Issued at (Unix timestamp)
|
|
"iss": "https://your-app.clerk.accounts.dev", // Issuer (Clerk instance URL)
|
|
"jti": "10db7f531a90cb2faea4", // JWT ID (unique identifier)
|
|
"nbf": 1639398220, // Not before (Unix timestamp)
|
|
"sub": "user_1deJLArSTiWiF1YdsEWysnhJLLY" // Subject (user ID)
|
|
}
|
|
```
|
|
|
|
### Claim Descriptions
|
|
|
|
| Claim | Name | Description | Can Override? |
|
|
|-------|------|-------------|---------------|
|
|
| `azp` | Authorized Party | The URL of your application | ❌ No |
|
|
| `exp` | Expiration Time | When the token expires (Unix timestamp) | ❌ No |
|
|
| `iat` | Issued At | When the token was created (Unix timestamp) | ❌ No |
|
|
| `iss` | Issuer | Your Clerk instance URL | ❌ No |
|
|
| `jti` | JWT ID | Unique identifier for this token | ❌ No |
|
|
| `nbf` | Not Before | Token not valid before this time (Unix timestamp) | ❌ No |
|
|
| `sub` | Subject | User ID (same as `userId` in auth objects) | ❌ No |
|
|
|
|
**IMPORTANT**: These claims consume approximately **200-300 bytes** of the 4KB cookie limit, leaving ~1.2KB for custom claims.
|
|
|
|
---
|
|
|
|
## User Property Shortcodes
|
|
|
|
Shortcodes are placeholder strings that Clerk replaces with actual user data during token generation. Use double curly braces: `{{shortcode}}`.
|
|
|
|
### Basic User Properties
|
|
|
|
```json
|
|
{
|
|
"user_id": "{{user.id}}", // User's unique ID
|
|
"first_name": "{{user.first_name}}", // User's first name (or null)
|
|
"last_name": "{{user.last_name}}", // User's last name (or null)
|
|
"full_name": "{{user.full_name}}", // User's full name (or null)
|
|
"email": "{{user.primary_email_address}}", // Primary email address
|
|
"phone": "{{user.primary_phone_address}}", // Primary phone number (or null)
|
|
"avatar": "{{user.image_url}}", // User's profile image URL
|
|
"created_at": "{{user.created_at}}", // Account creation timestamp
|
|
"username": "{{user.username}}", // Username (if enabled)
|
|
"email_verified": "{{user.email_verified}}", // Boolean: email verified?
|
|
"phone_verified": "{{user.phone_number_verified}}" // Boolean: phone verified?
|
|
}
|
|
```
|
|
|
|
### Verification Status
|
|
|
|
```json
|
|
{
|
|
"has_verified_email": "{{user.email_verified}}",
|
|
"has_verified_phone": "{{user.phone_number_verified}}",
|
|
"has_verified_contact": "{{user.email_verified || user.phone_number_verified}}"
|
|
}
|
|
```
|
|
|
|
### Complete User Shortcode Reference
|
|
|
|
| Shortcode | Type | Description | Example Value |
|
|
|-----------|------|-------------|---------------|
|
|
| `{{user.id}}` | string | User's unique identifier | `"user_2abc..."` |
|
|
| `{{user.first_name}}` | string \| null | First name | `"John"` |
|
|
| `{{user.last_name}}` | string \| null | Last name | `"Doe"` |
|
|
| `{{user.full_name}}` | string \| null | Full name (computed) | `"John Doe"` |
|
|
| `{{user.primary_email_address}}` | string | Primary email | `"john@example.com"` |
|
|
| `{{user.primary_phone_address}}` | string \| null | Primary phone | `"+1234567890"` |
|
|
| `{{user.image_url}}` | string | Profile image URL | `"https://..."` |
|
|
| `{{user.created_at}}` | number | Unix timestamp | `1639398272` |
|
|
| `{{user.username}}` | string \| null | Username (if enabled) | `"johndoe"` |
|
|
| `{{user.email_verified}}` | boolean | Email verified? | `true` |
|
|
| `{{user.phone_number_verified}}` | boolean | Phone verified? | `false` |
|
|
| `{{user.public_metadata}}` | object | All public metadata | `{...}` |
|
|
| `{{user.unsafe_metadata}}` | object | All unsafe metadata | `{...}` |
|
|
|
|
---
|
|
|
|
## Organization Claims
|
|
|
|
Clerk includes claims for the **active organization** (if user is in one and has selected it).
|
|
|
|
### Active Organization Claims
|
|
|
|
```json
|
|
{
|
|
"org_id": "org_2abc...", // Active organization ID
|
|
"org_slug": "acme-corp", // Active organization slug
|
|
"org_role": "org:admin" // User's role in active organization
|
|
}
|
|
```
|
|
|
|
**CRITICAL CHANGE (Core 2)**:
|
|
- ✅ **New**: `org_id`, `org_slug`, `org_role` (active org only)
|
|
- ❌ **Removed**: `orgs` claim (previously contained all user organizations)
|
|
|
|
### Accessing Organization Data in Templates
|
|
|
|
```json
|
|
{
|
|
"organization": {
|
|
"id": "{{user.public_metadata.org_id}}",
|
|
"name": "{{user.public_metadata.org_name}}",
|
|
"role": "{{user.public_metadata.org_role}}"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Note**: For all organizations (not just active), you must:
|
|
1. Store org data in `user.public_metadata` via Clerk API
|
|
2. Use custom JWT template to include it
|
|
|
|
---
|
|
|
|
## Metadata Access
|
|
|
|
Clerk provides two metadata fields for storing custom user data:
|
|
|
|
### Public Metadata
|
|
- ✅ Accessible on client side
|
|
- ✅ Included in user objects
|
|
- ✅ Can be included in JWT templates
|
|
- ❌ Should NOT contain sensitive data
|
|
|
|
### Unsafe Metadata
|
|
- ⚠️ Name is misleading - it's for "unvalidated" data
|
|
- ✅ Accessible on client side
|
|
- ✅ Can be included in JWT templates
|
|
- ❌ Should NOT contain sensitive data
|
|
|
|
### Basic Metadata Access
|
|
|
|
```json
|
|
{
|
|
"all_public": "{{user.public_metadata}}",
|
|
"all_unsafe": "{{user.unsafe_metadata}}"
|
|
}
|
|
```
|
|
|
|
**Result**:
|
|
```json
|
|
{
|
|
"all_public": {
|
|
"role": "admin",
|
|
"department": "engineering"
|
|
},
|
|
"all_unsafe": {
|
|
"onboardingComplete": true
|
|
}
|
|
}
|
|
```
|
|
|
|
### Nested Metadata with Dot Notation
|
|
|
|
For nested objects, use dot notation to access specific fields:
|
|
|
|
**User's public_metadata**:
|
|
```json
|
|
{
|
|
"profile": {
|
|
"interests": ["hiking", "knitting"],
|
|
"bio": "Software engineer passionate about..."
|
|
},
|
|
"addresses": {
|
|
"Home": "2355 Pointe Lane, 56301 Minnesota",
|
|
"Work": "3759 Newton Street, 33487 Florida"
|
|
},
|
|
"role": "admin",
|
|
"department": "engineering"
|
|
}
|
|
```
|
|
|
|
**JWT Template**:
|
|
```json
|
|
{
|
|
"role": "{{user.public_metadata.role}}",
|
|
"department": "{{user.public_metadata.department}}",
|
|
"interests": "{{user.public_metadata.profile.interests}}",
|
|
"home_address": "{{user.public_metadata.addresses.Home}}"
|
|
}
|
|
```
|
|
|
|
**Generated Token**:
|
|
```json
|
|
{
|
|
"role": "admin",
|
|
"department": "engineering",
|
|
"interests": ["hiking", "knitting"],
|
|
"home_address": "2355 Pointe Lane, 56301 Minnesota"
|
|
}
|
|
```
|
|
|
|
**Why This Matters**: Dot notation prevents including the entire metadata object, reducing token size.
|
|
|
|
---
|
|
|
|
## Advanced Template Features
|
|
|
|
### String Interpolation
|
|
|
|
Combine multiple shortcodes into a single string:
|
|
|
|
```json
|
|
{
|
|
"full_name": "{{user.last_name}} {{user.first_name}}",
|
|
"greeting": "Hello, {{user.first_name}}!",
|
|
"email_with_name": "{{user.full_name}} <{{user.primary_email_address}}>"
|
|
}
|
|
```
|
|
|
|
**Result**:
|
|
```json
|
|
{
|
|
"full_name": "Doe John",
|
|
"greeting": "Hello, John!",
|
|
"email_with_name": "John Doe <john@example.com>"
|
|
}
|
|
```
|
|
|
|
**IMPORTANT**: Interpolated values are **always strings**, even if null values are present.
|
|
|
|
### Conditional Expressions (Fallbacks)
|
|
|
|
Use `||` operator to provide fallback values:
|
|
|
|
```json
|
|
{
|
|
"full_name": "{{user.full_name || 'Guest User'}}",
|
|
"age": "{{user.public_metadata.age || 18}}",
|
|
"role": "{{user.public_metadata.role || 'user'}}",
|
|
"verified": "{{user.email_verified || user.phone_number_verified}}"
|
|
}
|
|
```
|
|
|
|
**How It Works**:
|
|
- Returns first **non-falsy** operand
|
|
- Final operand serves as default
|
|
- String literals require **single quotes**: `'default'`
|
|
- Can chain multiple fallbacks
|
|
|
|
**Example with Multiple Fallbacks**:
|
|
```json
|
|
{
|
|
"age": "{{user.public_metadata.age || user.unsafe_metadata.age || 18}}"
|
|
}
|
|
```
|
|
|
|
This checks:
|
|
1. `user.public_metadata.age` (if null/false, continue)
|
|
2. `user.unsafe_metadata.age` (if null/false, continue)
|
|
3. `18` (default value)
|
|
|
|
### Boolean Checks
|
|
|
|
```json
|
|
{
|
|
"has_verified_contact": "{{user.email_verified || user.phone_number_verified}}",
|
|
"is_complete": "{{user.public_metadata.profileComplete || false}}"
|
|
}
|
|
```
|
|
|
|
**Result**:
|
|
```json
|
|
{
|
|
"has_verified_contact": true, // If either email or phone is verified
|
|
"is_complete": false // If profileComplete is not set
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Creating JWT Templates
|
|
|
|
### Step 1: Navigate to Clerk Dashboard
|
|
|
|
1. Go to **Sessions** page in Clerk Dashboard
|
|
2. Click **Customize session token**
|
|
3. Choose **Create template** or use pre-built templates
|
|
|
|
### Step 2: Configure Template Properties
|
|
|
|
**Template Properties**:
|
|
```json
|
|
{
|
|
"name": "supabase", // Unique identifier (lowercase, no spaces)
|
|
"lifetime": 3600, // Token expiration in seconds (default: 60)
|
|
"allowed_clock_skew": 5, // Leeway for clock differences (default: 5)
|
|
"claims": { // Your custom claims
|
|
"email": "{{user.primary_email_address}}",
|
|
"role": "{{user.public_metadata.role}}"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Step 3: Use Template in Code
|
|
|
|
**React / Next.js**:
|
|
```typescript
|
|
import { useAuth } from '@clerk/nextjs'
|
|
|
|
function MyComponent() {
|
|
const { getToken } = useAuth()
|
|
|
|
// Get token with custom template
|
|
const token = await getToken({ template: 'supabase' })
|
|
|
|
// Use token to authenticate with external service
|
|
const response = await fetch('https://api.supabase.com/endpoint', {
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`
|
|
}
|
|
})
|
|
}
|
|
```
|
|
|
|
**Cloudflare Workers**:
|
|
```typescript
|
|
import { clerkClient } from '@clerk/backend'
|
|
|
|
// Generate token for user
|
|
const token = await clerkClient.users.getUserOauthAccessToken(
|
|
userId,
|
|
'supabase' // Template name
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## TypeScript Type Safety
|
|
|
|
Add type safety for custom claims with global declarations:
|
|
|
|
**Create `types/globals.d.ts`**:
|
|
```typescript
|
|
export {}
|
|
|
|
declare global {
|
|
interface CustomJwtSessionClaims {
|
|
metadata: {
|
|
role?: 'admin' | 'moderator' | 'user'
|
|
onboardingComplete?: boolean
|
|
department?: string
|
|
organizationId?: string
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Usage**:
|
|
```typescript
|
|
import { auth } from '@clerk/nextjs/server'
|
|
|
|
export default async function Page() {
|
|
const { sessionClaims } = await auth()
|
|
|
|
// TypeScript now knows about these properties
|
|
const role = sessionClaims?.metadata?.role // Type: 'admin' | 'moderator' | 'user' | undefined
|
|
const isComplete = sessionClaims?.metadata?.onboardingComplete // Type: boolean | undefined
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Common Use Cases
|
|
|
|
### 1. Role-Based Access Control (RBAC)
|
|
|
|
**JWT Template**:
|
|
```json
|
|
{
|
|
"email": "{{user.primary_email_address}}",
|
|
"role": "{{user.public_metadata.role || 'user'}}",
|
|
"permissions": "{{user.public_metadata.permissions}}"
|
|
}
|
|
```
|
|
|
|
**Backend Verification**:
|
|
```typescript
|
|
app.get('/api/admin', async (c) => {
|
|
const sessionClaims = c.get('sessionClaims')
|
|
|
|
if (sessionClaims?.role !== 'admin') {
|
|
return c.json({ error: 'Forbidden' }, 403)
|
|
}
|
|
|
|
return c.json({ message: 'Admin data' })
|
|
})
|
|
```
|
|
|
|
### 2. Multi-Tenant Applications
|
|
|
|
**JWT Template**:
|
|
```json
|
|
{
|
|
"user_id": "{{user.id}}",
|
|
"email": "{{user.primary_email_address}}",
|
|
"org_id": "{{user.public_metadata.org_id}}",
|
|
"org_slug": "{{user.public_metadata.org_slug}}",
|
|
"org_role": "{{user.public_metadata.org_role}}"
|
|
}
|
|
```
|
|
|
|
**Usage**:
|
|
```typescript
|
|
// Filter data by organization
|
|
const data = await db.query('SELECT * FROM items WHERE org_id = ?', [
|
|
sessionClaims.org_id
|
|
])
|
|
```
|
|
|
|
### 3. Supabase Integration
|
|
|
|
**JWT Template** (Name: `supabase`):
|
|
```json
|
|
{
|
|
"email": "{{user.primary_email_address}}",
|
|
"app_metadata": {
|
|
"provider": "clerk"
|
|
},
|
|
"user_metadata": {
|
|
"full_name": "{{user.full_name}}",
|
|
"avatar_url": "{{user.image_url}}"
|
|
}
|
|
}
|
|
```
|
|
|
|
**Usage**:
|
|
```typescript
|
|
import { createClient } from '@supabase/supabase-js'
|
|
|
|
const token = await getToken({ template: 'supabase' })
|
|
|
|
const supabase = createClient(
|
|
process.env.SUPABASE_URL,
|
|
process.env.SUPABASE_ANON_KEY,
|
|
{
|
|
global: {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`
|
|
}
|
|
}
|
|
}
|
|
)
|
|
```
|
|
|
|
### 4. Grafbase GraphQL Integration
|
|
|
|
**JWT Template** (Name: `grafbase`):
|
|
```json
|
|
{
|
|
"sub": "{{user.id}}",
|
|
"groups": ["org:admin", "org:member"]
|
|
}
|
|
```
|
|
|
|
**Grafbase Configuration**:
|
|
```toml
|
|
[auth.providers.clerk]
|
|
issuer = "https://your-app.clerk.accounts.dev"
|
|
jwks = "https://your-app.clerk.accounts.dev/.well-known/jwks.json"
|
|
```
|
|
|
|
---
|
|
|
|
## Limitations & Gotchas
|
|
|
|
### 1. Token Size Limit: 1.2KB for Custom Claims
|
|
|
|
**Problem**: Browsers limit cookies to 4KB. Clerk's default claims consume ~2.8KB, leaving **1.2KB for custom claims**.
|
|
|
|
**Solution**:
|
|
- ✅ Use JWT for minimal claims (user ID, role, email)
|
|
- ✅ Store large data (bio, preferences) in your database
|
|
- ✅ Fetch additional data after authentication
|
|
- ❌ Don't include large objects or arrays
|
|
|
|
**Bad Example** (Exceeds limit):
|
|
```json
|
|
{
|
|
"email": "{{user.primary_email_address}}",
|
|
"bio": "{{user.public_metadata.bio}}", // 6KB bio field
|
|
"all_metadata": "{{user.public_metadata}}" // Entire object
|
|
}
|
|
```
|
|
|
|
**Good Example** (Under limit):
|
|
```json
|
|
{
|
|
"user_id": "{{user.id}}",
|
|
"email": "{{user.primary_email_address}}",
|
|
"role": "{{user.public_metadata.role}}"
|
|
}
|
|
```
|
|
|
|
### 2. Reserved Claims
|
|
|
|
These claims are **reserved** and cannot be used in custom templates:
|
|
|
|
| Claim | Reason |
|
|
|-------|--------|
|
|
| `azp` | Auto-included default claim |
|
|
| `exp` | Auto-included default claim |
|
|
| `iat` | Auto-included default claim |
|
|
| `iss` | Auto-included default claim |
|
|
| `jti` | Auto-included default claim |
|
|
| `nbf` | Auto-included default claim |
|
|
| `sub` | Auto-included default claim |
|
|
|
|
**Error**: Attempting to override reserved claims returns HTTP 400 with error code `jwt_template_reserved_claim`.
|
|
|
|
### 3. Session-Bound Claims
|
|
|
|
These claims are **session-specific** and cannot be included in **custom JWTs** (but ARE included in default session tokens):
|
|
|
|
| Claim | Description |
|
|
|-------|-------------|
|
|
| `sid` | Session ID |
|
|
| `v` | Version |
|
|
| `pla` | Plan |
|
|
| `fea` | Features |
|
|
|
|
**Why**: Custom JWTs are generated on-demand and not tied to a specific session.
|
|
|
|
**Solution**: If you need session data, use **custom session tokens** instead of custom JWTs.
|
|
|
|
### 4. Invalid Shortcodes Resolve to `null`
|
|
|
|
```json
|
|
{
|
|
"valid": "{{user.first_name}}",
|
|
"invalid": "{{user.i_dont_exist}}"
|
|
}
|
|
```
|
|
|
|
**Result**:
|
|
```json
|
|
{
|
|
"valid": "John",
|
|
"invalid": null
|
|
}
|
|
```
|
|
|
|
**Prevention**: Always test templates with real user data before deploying.
|
|
|
|
### 5. Custom JWTs May Incur Latency
|
|
|
|
- Default session tokens are cached
|
|
- Custom JWTs require fetching user data on-demand
|
|
- This can add **50-200ms** to token generation
|
|
|
|
**When to Use Custom JWTs**:
|
|
- ✅ Integrating with third-party services (Supabase, Hasura, Grafbase)
|
|
- ✅ API-to-API authentication
|
|
- ❌ Not recommended for every frontend request (use default session tokens)
|
|
|
|
### 6. Organization Data Limitations
|
|
|
|
- Only **active organization** data is included by default
|
|
- For all organizations, store in `user.public_metadata` and use custom template
|
|
- `orgs` claim was removed in Core 2 (breaking change)
|
|
|
|
### 7. Metadata Sync Timing
|
|
|
|
- Metadata changes may take a few seconds to propagate to tokens
|
|
- Use `user.reload()` in frontend to refresh user object
|
|
- Backend should always fetch fresh data for critical operations
|
|
|
|
---
|
|
|
|
## Official Documentation
|
|
|
|
- **JWT Templates Guide**: https://clerk.com/docs/guides/sessions/jwt-templates
|
|
- **Custom Session Tokens**: https://clerk.com/docs/backend-requests/making/custom-session-token
|
|
- **Session Claims Access**: https://clerk.com/docs/upgrade-guides/core-2/node
|
|
- **Backend SDK**: https://clerk.com/docs/reference/backend/overview
|
|
- **Supabase Integration**: https://clerk.com/docs/guides/development/integrations/databases/supabase
|
|
- **Grafbase Integration**: https://clerk.com/docs/guides/development/integrations/databases/grafbase
|
|
|
|
---
|
|
|
|
## Quick Reference: All Available Shortcodes
|
|
|
|
### User Properties
|
|
```
|
|
{{user.id}}
|
|
{{user.first_name}}
|
|
{{user.last_name}}
|
|
{{user.full_name}}
|
|
{{user.primary_email_address}}
|
|
{{user.primary_phone_address}}
|
|
{{user.image_url}}
|
|
{{user.created_at}}
|
|
{{user.username}}
|
|
{{user.email_verified}}
|
|
{{user.phone_number_verified}}
|
|
```
|
|
|
|
### Metadata
|
|
```
|
|
{{user.public_metadata}}
|
|
{{user.public_metadata.FIELD_NAME}}
|
|
{{user.public_metadata.nested.field}}
|
|
{{user.unsafe_metadata}}
|
|
{{user.unsafe_metadata.FIELD_NAME}}
|
|
```
|
|
|
|
### Operators
|
|
```
|
|
{{shortcode1 || shortcode2}} // Fallback
|
|
{{shortcode || 'default'}} // Default value
|
|
"{{shortcode1}} {{shortcode2}}" // String interpolation
|
|
```
|
|
|
|
---
|
|
|
|
**Last Updated**: 2025-10-22
|
|
**Verified Against**: Clerk API v2025-04-10
|
|
**Production Tested**: ✅ Multiple frameworks
|