Loading learning content...
A JWT without proper claims design is like a passport without stamps—technically valid but practically useless for determining what the bearer can do. Claims are the semantic heart of JWTs, carrying the authorization context that services use to make access control decisions.
But claims are also where most JWT security vulnerabilities originate. Insufficient validation, missing checks, or over-trusting claim values has led to countless authentication bypasses, privilege escalations, and data breaches. A secure JWT implementation isn't just about cryptographic signatures—it's about rigorous claim validation.
This page teaches you to design claims that serve your authorization needs while building validation pipelines that reject malformed, expired, or malicious tokens with zero tolerance.
By the end of this page, you will master claims design for real-world applications, understand every registered claim and when to use it, implement comprehensive validation logic, and recognize common validation mistakes that lead to security vulnerabilities.
The JWT specification (RFC 7519) defines seven registered claims. While all are optional according to the spec, production systems typically require several of them. Understanding each claim's purpose, format, and validation requirements is essential.
iss (Issuer) ClaimThe iss claim identifies who created and signed the token. It's typically a URI pointing to your authentication service.
Purpose: Allows receiving services to verify the token came from a trusted source.
Format: Case-sensitive string, usually a URL (but can be any string identifier).
Validation Rule: Compare against a whitelist of trusted issuers. Reject tokens from unknown issuers immediately.
123456789101112131415161718192021
// Correct issuer validationconst TRUSTED_ISSUERS = [ 'https://auth.example.com', 'https://auth.staging.example.com',]; function validateIssuer(payload: JwtPayload): void { if (!payload.iss) { throw new ValidationError('Token missing issuer claim'); } if (!TRUSTED_ISSUERS.includes(payload.iss)) { // Log for security monitoring logger.warn('Token from untrusted issuer', { issuer: payload.iss }); throw new ValidationError('Token from untrusted issuer'); }} // Common mistake: Substring matching (VULNERABLE!)// DON'T DO THIS - attacker could register "auth.example.com.evil.com"if (payload.iss.includes('example.com')) { /* WRONG! */ }sub (Subject) ClaimThe sub claim identifies the principal (usually the user) that is the subject of the JWT.
Purpose: Uniquely identifies who the token represents.
Format: Case-sensitive string. Should be locally unique or globally unique within the issuer's context.
Design Principles:
sub breaks sessions and audit trailsiss for global uniqueness: iss + sub = unique identity1234567891011121314151617
// Good: Immutable internal ID{ "sub": "usr_7a3b9c2d4e5f" } // Bad: Email (can change){ "sub": "john@example.com" } // User changes email = identity confusion // Bad: Username (can change){ "sub": "john_doe" } // User renames = broken references // Multi-tenant consideration: namespace subjects{ "sub": "usr_123", "tenant_id": "org_456" // Or use iss per tenant} // Or use composite subject{ "sub": "org_456:usr_123" } // Clear ownershipaud (Audience) ClaimThe aud claim identifies the intended recipient(s) of the token.
Purpose: Prevents tokens intended for one service from being accepted by another.
Format: String or array of strings. Each value is typically a service URL or identifier.
Critical Security Role: Without audience validation, a token meant for api-a.example.com could be used to access api-b.example.com if both share the same signing key.
1234567891011121314151617181920212223242526
// Token payload{ "aud": ["https://api.example.com", "https://analytics.example.com"]} // Service-side validationconst MY_AUDIENCE = 'https://api.example.com'; function validateAudience(payload: JwtPayload): void { if (!payload.aud) { throw new ValidationError('Token missing audience claim'); } // aud can be string or array const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; if (!audiences.includes(MY_AUDIENCE)) { throw new ValidationError('Token not intended for this service'); }} // Common vulnerability: Accepting ANY valid token// If api-admin.example.com doesn't check audience,// a regular user token for api.example.com could be replayed thereAudience confusion is a class of attacks where tokens are replayed to unintended services. In microservices with shared secrets, this is especially dangerous. Always validate the audience claim matches your service's identifier exactly. Never skip this check 'because we trust our internal services.'
Three registered claims control the temporal validity of tokens: exp, nbf, and iat. These are among the most critical claims because they determine when tokens are valid.
All time values are Unix timestamps—seconds since January 1, 1970 UTC. Not milliseconds, not ISO 8601 strings.
exp (Expiration Time) ClaimThe exp claim identifies the expiration time after which the JWT MUST NOT be accepted for processing.
Purpose: Limits the window during which a compromised token can be used.
Validation Rule: current_time < exp
This is arguably the most important claim to validate. Without expiration validation, stolen tokens remain valid indefinitely.
123456789101112131415161718192021222324252627282930313233343536373839
// Setting expiration (auth server)function createToken(userId: string): string { const now = Math.floor(Date.now() / 1000); return jwt.sign({ sub: userId, iat: now, exp: now + 3600, // Expires in 1 hour }, secret);} // Validating expiration (consuming service)function validateExpiration(payload: JwtPayload): void { if (!payload.exp) { throw new ValidationError('Token missing expiration'); } const now = Math.floor(Date.now() / 1000); if (now >= payload.exp) { throw new ValidationError('Token has expired'); }} // With clock tolerance (recommended)const CLOCK_TOLERANCE_SECONDS = 30; function validateExpirationWithTolerance(payload: JwtPayload): void { if (!payload.exp) { throw new ValidationError('Token missing expiration'); } const now = Math.floor(Date.now() / 1000); // Allow 30 seconds of clock drift if (now >= payload.exp + CLOCK_TOLERANCE_SECONDS) { throw new ValidationError('Token has expired'); }}nbf (Not Before) ClaimThe nbf claim identifies the time before which the JWT MUST NOT be accepted.
Purpose: Allows issuing tokens that become valid in the future.
Validation Rule: current_time >= nbf
Use Cases:
iat (Issued At) ClaimThe iat claim identifies when the token was issued.
Purpose: Provides a timestamp of token creation for auditing and maximum age enforcement.
Validation Uses:
12345678910111213141516171819202122232425262728293031323334353637
// Maximum token age validationconst MAX_TOKEN_AGE_SECONDS = 86400; // 24 hours function validateTokenAge(payload: JwtPayload): void { if (!payload.iat) { // Depending on policy, missing iat could be an error logger.warn('Token missing iat claim'); return; } const now = Math.floor(Date.now() / 1000); const tokenAge = now - payload.iat; if (tokenAge > MAX_TOKEN_AGE_SECONDS) { throw new ValidationError('Token exceeds maximum age'); }} // Invalidate tokens issued before password changeasync function validatePostPasswordChange( payload: JwtPayload, userId: string): Promise<void> { const user = await userRepository.findById(userId); if (!user.passwordChangedAt) return; const passwordChangedTimestamp = Math.floor( user.passwordChangedAt.getTime() / 1000 ); if (payload.iat && payload.iat < passwordChangedTimestamp) { throw new ValidationError( 'Token issued before password change - please re-authenticate' ); }}Time-based validation assumes synchronized clocks between issuer and validator. In distributed systems, clock drift can cause valid tokens to be rejected. Use NTP on all servers and implement a clock tolerance (typically 30-60 seconds) to handle minor drift. However, don't set tolerance too high—it extends the effective token lifetime.
The jti (JWT ID) claim provides a unique identifier for the token. While optional, it's essential for certain security requirements.
Purpose: Uniquely identifies individual tokens, enabling:
Format: String, must be unique within the issuer's domain. Typically a UUID or similar.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
import { randomUUID } from 'crypto'; // Token creation with jtifunction createToken(userId: string): string { return jwt.sign({ sub: userId, jti: randomUUID(), // e.g., "550e8400-e29b-41d4-a716-446655440000" iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 3600, }, secret);} // Token revocation using jti (Redis-based blocklist)interface TokenBlocklist { add(jti: string, expiresAt: number): Promise<void>; contains(jti: string): Promise<boolean>;} class RedisTokenBlocklist implements TokenBlocklist { constructor(private redis: RedisClient) {} async add(jti: string, expiresAt: number): Promise<void> { // Store until token would have expired anyway const ttlSeconds = expiresAt - Math.floor(Date.now() / 1000); if (ttlSeconds > 0) { await this.redis.setex(`blocklist:${jti}`, ttlSeconds, '1'); } } async contains(jti: string): Promise<boolean> { const result = await this.redis.get(`blocklist:${jti}`); return result !== null; }} // Validation middlewareasync function validateNotRevoked( payload: JwtPayload, blocklist: TokenBlocklist): Promise<void> { if (!payload.jti) { // Policy decision: require jti or allow without throw new ValidationError('Token missing jti claim'); } if (await blocklist.contains(payload.jti)) { throw new ValidationError('Token has been revoked'); }} // One-time use tokens (for sensitive operations)class OneTimeTokenValidator { private usedTokens: Set<string> = new Set(); async validateAndConsume(jti: string): Promise<boolean> { if (this.usedTokens.has(jti)) { return false; // Already used } this.usedTokens.add(jti); return true; }}Using jti for revocation requires a shared blocklist (Redis, database). This reintroduces some statefulness. For high-security scenarios, this trade-off is worthwhile. For lower-security scenarios, short expiration times may be sufficient without explicit revocation.
Beyond registered claims, applications add custom claims for authorization decisions. Designing these claims well is crucial for both security and maintainability.
Naming Conventions:
To avoid collisions with future registered claims or claims from other systems, custom claims should use one of these patterns:
https://example.com/roles — URI namespace prevents collisionsmyapp_roles, myapp_permissions — simple but less formalroles, perms — acceptable if you control all consumersiss, sub, aud, exp, nbf, iat, jti for custom purposes1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
// Pattern 1: Flat role list{ "sub": "usr_123", "roles": ["user", "editor", "billing_admin"]} // Pattern 2: Scoped permissions (OAuth2-style){ "sub": "usr_123", "scope": "read:users write:articles delete:comments"} // Pattern 3: Resource-specific permissions{ "sub": "usr_123", "permissions": { "users": ["read", "list"], "articles": ["read", "write", "delete"], "comments": ["read", "write"] }} // Pattern 4: Minimal claims with role lookup{ "sub": "usr_123", "roles": ["editor"] // Actual permissions resolved from database based on role} // Pattern 5: Multi-tenant with tenant context{ "sub": "usr_123", "tenant_id": "org_456", "tenant_role": "admin", "global_role": "user"} // Anti-pattern: Too much data (increases token size){ "sub": "usr_123", "full_name": "John Doe", "email": "john@example.com", "phone": "+1-555-0123", "address": { "street": "...", "city": "...", "country": "..." }, "preferences": { /* pages of preferences */ }, "order_history": [ /* hundreds of orders */ ]}Custom Claims Best Practices:
| Principle | Rationale | Example |
|---|---|---|
| Minimize claim count | Reduces token size, attack surface | Include role IDs, not full permission lists |
| Use stable identifiers | Prevents broken references | org_id: 123 not org_name: 'Acme' |
| Avoid sensitive data | Payload is readable | User ID yes, SSN no |
| Consider claim longevity | Token lives until expiration | Don't embed rapidly-changing data |
| Version your claim schema | Allows evolution | claims_version: 2 |
| Document claim semantics | Prevents misinterpretation | What does 'admin' mean exactly? |
Claims are set at token creation and don't change until the token expires. If you include permissions in the token and a user's permissions are revoked, they retain access until the token expires. For sensitive systems, use short-lived tokens and/or real-time permission checks.
Production JWT validation requires multiple checks performed in a specific order. Failing to implement any check can create security vulnerabilities. Here's the complete validation pipeline:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141
interface ValidationConfig { trustedIssuers: string[]; expectedAudience: string; algorithms: string[]; clockToleranceSeconds: number; requireJti: boolean; maxTokenAgeSeconds?: number;} interface ValidatedToken { header: JwtHeader; payload: JwtPayload; signature: string;} class JWTValidator { constructor( private config: ValidationConfig, private keyProvider: KeyProvider, private blocklist?: TokenBlocklist ) {} async validate(token: string): Promise<ValidatedToken> { // Step 1: Structure validation const parts = this.validateStructure(token); // Step 2: Decode header (without verification yet) const header = this.decodeHeader(parts[0]); // Step 3: Algorithm validation this.validateAlgorithm(header); // Step 4: Get signing key const key = await this.keyProvider.getKey(header.kid); // Step 5: Signature verification this.verifySignature(token, key, header.alg); // Step 6: Decode payload const payload = this.decodePayload(parts[1]); // Step 7: Registered claims validation this.validateRegisteredClaims(payload); // Step 8: Revocation check (if jti present) if (this.blocklist && payload.jti) { await this.validateNotRevoked(payload.jti); } return { header, payload, signature: parts[2], }; } private validateStructure(token: string): string[] { if (typeof token !== 'string') { throw new ValidationError('Token must be a string'); } const parts = token.split('.'); if (parts.length !== 3) { throw new ValidationError('Token must have 3 parts'); } // Each part must be valid Base64URL for (const part of parts) { if (!/^[A-Za-z0-9_-]+$/.test(part)) { throw new ValidationError('Invalid Base64URL encoding'); } } return parts; } private validateAlgorithm(header: JwtHeader): void { if (!header.alg) { throw new ValidationError('Token missing algorithm'); } // CRITICAL: Explicitly check against allowed algorithms if (!this.config.algorithms.includes(header.alg)) { throw new ValidationError(`Algorithm ${header.alg} not allowed`); } // CRITICAL: Explicitly reject "none" algorithm if (header.alg.toLowerCase() === 'none') { throw new ValidationError('Algorithm "none" is not permitted'); } } private validateRegisteredClaims(payload: JwtPayload): void { const now = Math.floor(Date.now() / 1000); const tolerance = this.config.clockToleranceSeconds; // Validate issuer if (!this.config.trustedIssuers.includes(payload.iss!)) { throw new ValidationError('Untrusted issuer'); } // Validate audience const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; if (!audiences.includes(this.config.expectedAudience)) { throw new ValidationError('Token not for this audience'); } // Validate expiration if (!payload.exp) { throw new ValidationError('Token missing expiration'); } if (now >= payload.exp + tolerance) { throw new ValidationError('Token expired'); } // Validate not-before (if present) if (payload.nbf && now < payload.nbf - tolerance) { throw new ValidationError('Token not yet valid'); } // Validate token age (if configured) if (this.config.maxTokenAgeSeconds && payload.iat) { if (now - payload.iat > this.config.maxTokenAgeSeconds) { throw new ValidationError('Token exceeds max age'); } } // Validate jti requirement if (this.config.requireJti && !payload.jti) { throw new ValidationError('Token missing jti'); } } private async validateNotRevoked(jti: string): Promise<void> { if (await this.blocklist!.contains(jti)) { throw new ValidationError('Token has been revoked'); } }}Security audits consistently find the same JWT validation mistakes. Understanding these anti-patterns helps you avoid them in your implementations.
algorithms: ['RS256']12345678910111213141516
// VULNERABLE: Library chooses algorithm from tokenjwt.verify(token, secretOrPublicKey); // Uses token's alg claim! // VULNERABLE: Accepting any algorithmjwt.verify(token, key, { algorithms: '*' }); // NEVER DO THIS // SECURE: Explicitly specify expected algorithmjwt.verify(token, publicKey, { algorithms: ['RS256'] // Only accept RS256}); // SECURE: Configuration-drivenconst config = { hmacSecret: process.env.JWT_SECRET, allowedAlgorithms: ['HS256'], // Whitelist, not blacklist};| Mistake | Vulnerability | Fix |
|---|---|---|
| Not validating exp | Tokens valid forever | Always check exp, require it |
| Not validating aud | Token replay across services | Verify aud matches your service |
| Using == for secrets | Timing attacks reveal secret | Use constant-time comparison |
| Logging tokens | Credential exposure | Log only jti or sub, never full token |
| Trusting claims blindly | Privilege escalation | Validate claim values, not just presence |
| Missing issuer check | Accept tokens from attackers | Whitelist trusted issuers |
| Weak HMAC secrets | Brute force attacks | Use cryptographically random ≥256-bit secrets |
JWTs are bearer tokens—possessing the token grants access. Logging full tokens creates a credential exposure risk. If logs are compromised, all logged tokens become usable by attackers. Log only the jti, sub, or a hash of the token for correlation.
Let's implement a production-ready claims validator that handles common scenarios: role-based access, multi-tenancy, and scope-based permissions.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
// Domain types for strong typingtype Role = 'user' | 'editor' | 'admin' | 'super_admin';type Permission = `${'read' | 'write' | 'delete'}:${'users' | 'articles' | 'settings'}`; interface AppJwtPayload extends JwtPayload { sub: string; tenant_id?: string; roles?: Role[]; permissions?: Permission[]; scope?: string; // OAuth2-style space-separated scopes} class ClaimsValidator { /** * Validate user has required role(s) */ requireRoles( payload: AppJwtPayload, requiredRoles: Role[], mode: 'any' | 'all' = 'any' ): void { if (!payload.roles || payload.roles.length === 0) { throw new AuthorizationError('User has no roles'); } if (mode === 'any') { const hasAny = requiredRoles.some(r => payload.roles!.includes(r)); if (!hasAny) { throw new AuthorizationError( `Requires one of: ${requiredRoles.join(', ')}` ); } } else { const hasAll = requiredRoles.every(r => payload.roles!.includes(r)); if (!hasAll) { throw new AuthorizationError( `Requires all of: ${requiredRoles.join(', ')}` ); } } } /** * Validate user has specific permission */ requirePermission( payload: AppJwtPayload, permission: Permission ): void { // Check explicit permissions if (payload.permissions?.includes(permission)) { return; } // Check OAuth2-style scopes if (payload.scope) { const scopes = payload.scope.split(' '); if (scopes.includes(permission)) { return; } } throw new AuthorizationError(`Missing permission: ${permission}`); } /** * Validate tenant context */ requireTenant( payload: AppJwtPayload, tenantId: string ): void { if (!payload.tenant_id) { throw new AuthorizationError('Token missing tenant context'); } if (payload.tenant_id !== tenantId) { // Don't reveal which tenant was expected throw new AuthorizationError('Access denied for this resource'); } } /** * Validate access to specific resource */ requireResourceAccess( payload: AppJwtPayload, resourceType: string, resourceOwnerId: string, requiredAccess: 'read' | 'write' | 'delete' ): void { // Admins can access everything if (payload.roles?.includes('admin') || payload.roles?.includes('super_admin')) { return; } // Check if user owns the resource if (payload.sub === resourceOwnerId) { return; } // Check explicit permission const permission = `${requiredAccess}:${resourceType}` as Permission; this.requirePermission(payload, permission); }} // Usage in route handlersconst validator = new ClaimsValidator(); app.delete('/api/articles/:id', authenticate, async (req, res) => { const payload = req.user as AppJwtPayload; const article = await articleService.findById(req.params.id); // Validate tenant first (if multi-tenant) validator.requireTenant(payload, article.tenantId); // Then validate access validator.requireResourceAccess( payload, 'articles', article.authorId, 'delete' ); await articleService.delete(req.params.id); res.status(204).send();});Never rely solely on claims for authorization. Use claims for initial filtering, then verify against your database of record. A user's role in the token might be stale—always check the current state for sensitive operations like deletion or privilege changes.
Mastering JWT claims and validation is the difference between a secure system and a vulnerable one. Let's consolidate the essential knowledge:
What's Next:
Now that you understand claims design and validation, the next page covers Token Expiration in depth. We'll explore expiration strategies, the trade-offs between short and long-lived tokens, and how to design token lifecycles that balance security with user experience.
You now have comprehensive knowledge of JWT claims design and validation. You understand registered claims, custom claim patterns, the complete validation pipeline, and common security mistakes to avoid. This knowledge is essential for building secure authentication systems. Next, we'll dive into token expiration strategies.