Loading content...
Every token-based system has a critical checkpoint: the moment when your application receives a token and must decide—is this token legitimate? Can I trust it? Should I grant access?
Token validation is the gatekeeper function. It's the defense that determines whether your protected resources are accessed by authorized users or malicious actors wielding forged, expired, or stolen tokens. Every security boundary in your system depends on validation being performed correctly.
The consequences of validation failures are severe:
This page equips you with comprehensive knowledge to implement token validation that withstands real-world attacks—not just the theory, but production-hardened patterns used by elite security teams.
By the end of this page, you will understand JWT structure and signature algorithms, implement a complete validation pipeline, handle edge cases and attack vectors, and build validation middleware for production systems.
Before we can validate JWTs, we must understand their structure intimately. A JWT consists of three parts, each base64url-encoded and separated by dots:
header.payload.signature
Example JWT:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImFiYzEyMyJ9.
eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyXzEyMzQ1Iiw
iYXVkIjoibXktYXBwLWlkIiwiZXhwIjoxNzA0MTIzNDU2LCJpYXQiOjE3MDQxMTk4NTYsInNjb3BlIjoicmVhZCB3cml0ZSJ9.
NLa5i7J4QGM9KvRf3yjH...(signature bytes)...
| Component | Content | Purpose | Security Role |
|---|---|---|---|
| Header | Algorithm (alg), Token type (typ), Key ID (kid) | Tells validator how to verify | Vulnerable to alg confusion attacks if not validated |
| Payload | Claims: iss, sub, aud, exp, iat, custom claims | Carries authorization/identity data | Must be validated against expected values |
| Signature | HMAC or RSA/EC signature of header.payload | Proves token wasn't tampered | Core security guarantee; never skip verification |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
// JWT Structure Visualizationinterface JWTHeader { alg: string; // Algorithm: "RS256", "ES256", "HS256", etc. typ: string; // Type: typically "JWT" kid?: string; // Key ID: identifies which key signed this token} interface JWTPayload { // Standard claims (registered) iss?: string; // Issuer: who created and signed this token sub?: string; // Subject: who this token is about (user ID) aud?: string | string[]; // Audience: intended recipient(s) exp?: number; // Expiration: unix timestamp when token expires nbf?: number; // Not Before: token invalid before this time iat?: number; // Issued At: when token was created jti?: string; // JWT ID: unique identifier for this token // OIDC-specific nonce?: string; // Prevents replay attacks auth_time?: number; // When user authenticated acr?: string; // Auth context class amr?: string[]; // Auth methods used // Custom claims scope?: string; // OAuth scopes roles?: string[]; // User roles permissions?: string[]; // Fine-grained permissions [key: string]: any; // Extension claims} // Decoding (NOT validating) a JWTfunction decodeJWT(token: string): { header: JWTHeader; payload: JWTPayload } { const parts = token.split('.'); if (parts.length !== 3) { throw new Error('Invalid JWT format'); } const header = JSON.parse( Buffer.from(parts[0], 'base64url').toString() ); const payload = JSON.parse( Buffer.from(parts[1], 'base64url').toString() ); // WARNING: This only decodes, it does NOT validate! // Never use decoded claims without signature verification return { header, payload };}Decoding base64 is NOT validation. Anyone can create a JWT with any claims—decoding just reads them. Signature verification proves the token came from the expected issuer and hasn't been modified. Never make authorization decisions based on decoded-but-unverified tokens.
Signature verification is the cryptographic core of JWT validation. It answers the question: Did the expected issuer create this token, and has it been modified?
How Signatures Work:
header.payload (the message)header.payload.signatureAlgorithm Types:
| Algorithm | Type | Key Material | Use Case | Notes |
|---|---|---|---|---|
| HS256 | Symmetric (HMAC) | Shared secret | First-party tokens only | Both issuer and validator need same secret |
| HS384/HS512 | Symmetric (HMAC) | Shared secret | Higher security symmetric | Longer hashes, same tradeoffs |
| RS256 | Asymmetric (RSA) | Public/Private key pair | Third-party IdPs, OIDC | Most common; private signs, public verifies |
| RS384/RS512 | Asymmetric (RSA) | Public/Private key pair | Higher security RSA | Longer signatures, same pattern |
| ES256 | Asymmetric (ECDSA) | EC key pair | Compact signatures | Smaller keys/signatures than RSA |
| ES384/ES512 | Asymmetric (ECDSA) | EC key pair | High security ECDSA | P-384 and P-521 curves |
| PS256 | Asymmetric (RSA-PSS) | RSA key pair | Enhanced RSA security | Probabilistic signature scheme |
| none | None! | None | NEVER USE | Disables signatures entirely—major vulnerability |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
// Secure Signature Verification with jose libraryimport * as jose from 'jose'; // ========================================// Asymmetric Verification (RS256, ES256)// ======================================== // For OIDC providers: Fetch public keys from JWKSasync function verifyWithJWKS( token: string, jwksUrl: string, expectedIssuer: string, expectedAudience: string): Promise<jose.JWTPayload> { // Create JWKS client (handles key caching, rotation) const JWKS = jose.createRemoteJWKSet(new URL(jwksUrl)); const { payload } = await jose.jwtVerify( token, JWKS, { issuer: expectedIssuer, audience: expectedAudience, // CRITICAL: Restrict allowed algorithms algorithms: ['RS256', 'ES256'], // Reject tokens from the future clockTolerance: 30, // 30 seconds tolerance for clock skew } ); return payload;} // For self-managed keys: Use public key directlyasync function verifyWithPublicKey( token: string, publicKeyPEM: string, expectedIssuer: string, expectedAudience: string): Promise<jose.JWTPayload> { const publicKey = await jose.importSPKI(publicKeyPEM, 'RS256'); const { payload } = await jose.jwtVerify( token, publicKey, { issuer: expectedIssuer, audience: expectedAudience, algorithms: ['RS256'], // Only accept expected algorithm } ); return payload;} // ========================================// Symmetric Verification (HS256)// ======================================== // WARNING: Only for first-party tokens where validator = issuerasync function verifyWithSecret( token: string, secret: string, expectedIssuer: string, expectedAudience: string): Promise<jose.JWTPayload> { // Secret must be sufficiently random (256+ bits for HS256) const secretKey = new TextEncoder().encode(secret); const { payload } = await jose.jwtVerify( token, secretKey, { issuer: expectedIssuer, audience: expectedAudience, algorithms: ['HS256'], // Explicit algorithm restriction } ); return payload;} // ========================================// Algorithm Confusion Prevention// ======================================== // WRONG: Naive verification vulnerable to alg confusionasync function vulnerableVerify(token: string, keyOrSecret: unknown) { const { header } = jose.decodeJwt(token); // DANGEROUS: Trusting the token's claimed algorithm! if (header.alg === 'none') { // Attacker removes signature entirely return jose.decodeJwt(token); // No verification! } if (header.alg.startsWith('HS')) { // Attacker switches RS256 to HS256, uses public key as HMAC secret return jose.jwtVerify(token, keyOrSecret as Uint8Array); } // This code is INSECURE - never use in production} // CORRECT: Algorithm is enforced, not trusted from tokenasync function secureVerify( token: string, key: jose.KeyLike, expectedAlgorithm: string): Promise<jose.JWTPayload> { const { payload } = await jose.jwtVerify(token, key, { algorithms: [expectedAlgorithm], // You decide the algorithm, not the token }); return payload;}Two notorious JWT attacks exploit algorithm handling: 1) Setting alg='none' to disable signature verification entirely. 2) Switching from RS256 to HS256 and using the public key as the HMAC secret. ALWAYS specify allowed algorithms explicitly—never derive the algorithm from the token itself.
Signature verification proves the token is authentic. Claims validation ensures the token applies to this request, at this time, for this audience.
Why Every Claim Matters:
| Claim | Validation Requirement | Attack if Skipped |
|---|---|---|
| iss (Issuer) | Must match expected authorization server URL exactly | Tokens from malicious IdPs accepted as legitimate |
| aud (Audience) | Must contain your client_id/application identifier | Tokens meant for other apps grant access to yours |
| exp (Expiration) | Must be in the future (with small clock tolerance) | Expired/revoked tokens remain usable forever |
| nbf (Not Before) | Current time must be after nbf | Pre-issued tokens valid before intended |
| iat (Issued At) | Should be in the past, not too old | Future-dated tokens or very old tokens accepted |
| nonce (OIDC) | Must match nonce sent in auth request | Token replay/injection attacks succeed |
| jti (Token ID) | Should be unique if checking deny lists | Token reuse after revocation not detected |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
// Comprehensive Claims Validationinterface ValidationConfig { issuer: string; audience: string | string[]; clockToleranceSeconds: number; maxTokenAgeSeconds?: number; requiredScopes?: string[]; requiredClaims?: string[];} interface ValidationResult { valid: boolean; payload?: Record<string, any>; error?: string;} class ClaimsValidator { constructor(private config: ValidationConfig) {} validate(payload: Record<string, any>, expectedNonce?: string): ValidationResult { try { // 1. Validate issuer (exact match required) this.validateIssuer(payload); // 2. Validate audience this.validateAudience(payload); // 3. Validate temporal claims this.validateTemporalClaims(payload); // 4. Validate nonce if provided (OIDC) if (expectedNonce) { this.validateNonce(payload, expectedNonce); } // 5. Validate required scopes if (this.config.requiredScopes) { this.validateScopes(payload, this.config.requiredScopes); } // 6. Validate required claims are present if (this.config.requiredClaims) { this.validateRequiredClaims(payload, this.config.requiredClaims); } return { valid: true, payload }; } catch (error) { return { valid: false, error: error instanceof Error ? error.message : 'Validation failed' }; } } private validateIssuer(payload: Record<string, any>): void { // Exact match - trailing slashes, protocol all matter if (payload.iss !== this.config.issuer) { throw new Error( `Invalid issuer: expected '${this.config.issuer}', got '${payload.iss}'` ); } } private validateAudience(payload: Record<string, any>): void { const tokenAudience = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; const expectedAudiences = Array.isArray(this.config.audience) ? this.config.audience : [this.config.audience]; const hasValidAudience = tokenAudience.some( aud => expectedAudiences.includes(aud) ); if (!hasValidAudience) { throw new Error( `Token not intended for this audience. Expected: ${expectedAudiences.join(', ')}` ); } // For multi-audience tokens, check azp (authorized party) if (tokenAudience.length > 1 && payload.azp) { if (!expectedAudiences.includes(payload.azp)) { throw new Error('Token authorized party does not match'); } } } private validateTemporalClaims(payload: Record<string, any>): void { const now = Math.floor(Date.now() / 1000); const tolerance = this.config.clockToleranceSeconds; // Token must not be expired if (payload.exp !== undefined) { if (now > payload.exp + tolerance) { throw new Error('Token has expired'); } } // Token must be valid now (not before) if (payload.nbf !== undefined) { if (now < payload.nbf - tolerance) { throw new Error('Token not yet valid'); } } // Token must have been issued in the past if (payload.iat !== undefined) { if (payload.iat > now + tolerance) { throw new Error('Token issued in the future'); } // Optional: Reject very old tokens if (this.config.maxTokenAgeSeconds) { const tokenAge = now - payload.iat; if (tokenAge > this.config.maxTokenAgeSeconds) { throw new Error('Token is too old'); } } } } private validateNonce(payload: Record<string, any>, expectedNonce: string): void { if (payload.nonce !== expectedNonce) { throw new Error('Nonce mismatch - possible token replay or injection'); } } private validateScopes(payload: Record<string, any>, required: string[]): void { const tokenScopes = (payload.scope || '').split(' ').filter(Boolean); for (const requiredScope of required) { if (!tokenScopes.includes(requiredScope)) { throw new Error(`Missing required scope: ${requiredScope}`); } } } private validateRequiredClaims( payload: Record<string, any>, required: string[] ): void { for (const claim of required) { if (!(claim in payload) || payload[claim] == null) { throw new Error(`Missing required claim: ${claim}`); } } }} // Usageconst validator = new ClaimsValidator({ issuer: 'https://auth.example.com', audience: 'my-app-client-id', clockToleranceSeconds: 30, maxTokenAgeSeconds: 3600, // 1 hour requiredScopes: ['read', 'write'], requiredClaims: ['sub', 'email'],}); const result = validator.validate(tokenPayload, sessionNonce);if (!result.valid) { throw new AuthError(`Token validation failed: ${result.error}`);}Server clocks aren't perfectly synchronized. Use a small tolerance (30-60 seconds) when checking exp, nbf, and iat. Too tight = legitimate tokens rejected. Too loose = security window expanded. Most libraries support a clockTolerance parameter—use it.
A valid token isn't enough—the token must authorize the specific action being attempted. Scope validation ensures the bearer has permission for the requested operation.
The Scope Model:
OAuth scopes represent coarse-grained permissions granted during authorization:
read - Read-only accesswrite - Modify resourcesdelete - Remove resourcesadmin - Administrative operationsMany systems extend this with more granular scopes:
users:read - Read user datapayments:write - Initiate paymentsreports:export - Export reports123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139
// Scope and Permission Validation Systemtype ScopePattern = string; // e.g., "users:read", "payments:*" interface ScopeValidationConfig { scopeClaim: string; // Usually 'scope' or 'scp' hierarchical: boolean; // Does 'users:*' include 'users:read'? caseSensitive: boolean;} class ScopeValidator { constructor(private config: ScopeValidationConfig = { scopeClaim: 'scope', hierarchical: true, caseSensitive: true, }) {} // Extract scopes from token payload extractScopes(payload: Record<string, any>): string[] { const scopeValue = payload[this.config.scopeClaim]; if (!scopeValue) { return []; } // Handle both space-delimited string and array formats if (Array.isArray(scopeValue)) { return scopeValue; } return scopeValue.split(' ').filter(Boolean); } // Check if token has specific scope hasScope(tokenScopes: string[], required: ScopePattern): boolean { const normalizedRequired = this.normalize(required); return tokenScopes.some(tokenScope => { const normalized = this.normalize(tokenScope); // Exact match if (normalized === normalizedRequired) { return true; } // Hierarchical match (admin includes everything) if (this.config.hierarchical) { // Check wildcard patterns if (this.matchesWildcard(normalizedRequired, normalized)) { return true; } // Check hierarchy (users:read:own covered by users:read:*) if (this.matchesHierarchy(normalizedRequired, normalized)) { return true; } } return false; }); } // Check if token has ALL required scopes hasAllScopes(tokenScopes: string[], required: ScopePattern[]): boolean { return required.every(scope => this.hasScope(tokenScopes, scope)); } // Check if token has ANY of the scopes hasAnyScope(tokenScopes: string[], required: ScopePattern[]): boolean { return required.some(scope => this.hasScope(tokenScopes, scope)); } private normalize(scope: string): string { return this.config.caseSensitive ? scope : scope.toLowerCase(); } private matchesWildcard(required: string, tokenScope: string): boolean { // users:* matches users:read, users:write, etc. if (tokenScope.endsWith('*')) { const prefix = tokenScope.slice(0, -1); return required.startsWith(prefix); } return false; } private matchesHierarchy(required: string, tokenScope: string): boolean { // admin:users includes admin:users:read const requiredParts = required.split(':'); const tokenParts = tokenScope.split(':'); // Token scope must be equal or more general if (tokenParts.length > requiredParts.length) { return false; } return tokenParts.every((part, index) => { if (part === '*') return true; return part === requiredParts[index]; }); }} // Express middleware for scope validationfunction requireScopes(...requiredScopes: string[]) { return (req: Request, res: Response, next: NextFunction) => { const tokenScopes = req.tokenPayload?.scope?.split(' ') || []; const validator = new ScopeValidator(); if (!validator.hasAllScopes(tokenScopes, requiredScopes)) { return res.status(403).json({ error: 'insufficient_scope', required: requiredScopes, provided: tokenScopes, }); } next(); };} // Route-level scope enforcementapp.get('/api/users', authenticateToken, requireScopes('users:read'), async (req, res) => { // Token has users:read scope const users = await getUsers(); res.json(users); }); app.post('/api/payments', authenticateToken, requireScopes('payments:write', 'financial:access'), async (req, res) => { // Token has both payments:write AND financial:access const payment = await createPayment(req.body); res.json(payment); });Scopes are consent-based (user grants them during authorization). Permissions are policy-based (determined by user's role/attributes). A user might grant 'payments:write' scope, but permission validation might still reject if they're not in the 'finance' role. Use scopes for OAuth consent, permissions for fine-grained RBAC/ABAC.
JWTs are self-contained—they don't require database lookups to validate. This is great for performance but terrible for revocation. A perfectly valid JWT can represent a revoked token.
When Revocation Matters:
Revocation Strategies:
| Strategy | How It Works | Latency Impact | Complexity |
|---|---|---|---|
| Short Expiration | Tokens expire in 5-15 min; revoke refresh token | None (no lookup) | Low |
| Deny List | Store revoked token IDs (jti); check on validation | Low (Redis lookup) | Medium |
| Token Version | Store user token version; reject older versions | Low (user lookup) | Medium |
| Token Introspection | Query auth server for every validation | High (HTTP call) | Low |
| Event Propagation | Publish revocation events; services update local cache | Variable | High |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
// Production Revocation Checking Implementationimport Redis from 'ioredis'; const redis = new Redis(process.env.REDIS_URL); // ========================================// Strategy 1: Deny List with Redis// ======================================== class TokenDenyList { private readonly prefix = 'token:revoked:'; async revokeToken(jti: string, expiresAt: number): Promise<void> { const now = Math.floor(Date.now() / 1000); const ttl = expiresAt - now; if (ttl > 0) { // Only store until token would have expired anyway await redis.set(`${this.prefix}${jti}`, '1', 'EX', ttl); } } async isRevoked(jti: string): Promise<boolean> { const result = await redis.get(`${this.prefix}${jti}`); return result !== null; } // Revoke all tokens for a user (logout everywhere) async revokeUserTokens(userId: string): Promise<void> { // This requires tracking all tokens per user, or... // Use token versioning instead (see below) }} // ========================================// Strategy 2: Token Versioning// ======================================== interface TokenVersionStore { getUserTokenVersion(userId: string): Promise<number>; incrementTokenVersion(userId: string): Promise<number>;} class TokenVersionValidator { constructor(private versionStore: TokenVersionStore) {} async isValidVersion(userId: string, tokenVersion?: number): Promise<boolean> { if (tokenVersion === undefined) { // Tokens without version are always invalid in this system return false; } const currentVersion = await this.versionStore.getUserTokenVersion(userId); return tokenVersion >= currentVersion; } async revokeAllUserTokens(userId: string): Promise<void> { await this.versionStore.incrementTokenVersion(userId); }} // ========================================// Strategy 3: Token Introspection (RFC 7662)// ======================================== interface IntrospectionResponse { active: boolean; scope?: string; client_id?: string; username?: string; token_type?: string; exp?: number; iat?: number; nbf?: number; sub?: string; aud?: string; iss?: string; jti?: string;} class TokenIntrospector { constructor( private introspectionEndpoint: string, private clientId: string, private clientSecret: string, ) {} async introspect(token: string): Promise<IntrospectionResponse> { const response = await fetch(this.introspectionEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Authorization': `Basic ${Buffer.from( `${this.clientId}:${this.clientSecret}` ).toString('base64')}`, }, body: new URLSearchParams({ token: token, token_type_hint: 'access_token', }), }); return response.json(); } async isTokenActive(token: string): Promise<boolean> { const result = await this.introspect(token); return result.active === true; }} // ========================================// Combined Validation with Revocation Check// ======================================== class TokenValidator { constructor( private jwtVerifier: JWTVerifier, private denyList: TokenDenyList, private versionValidator: TokenVersionValidator, ) {} async validateToken(token: string): Promise<TokenPayload> { // Step 1: Cryptographic validation (signature, expiry, audience) const payload = await this.jwtVerifier.verify(token); // Step 2: Check deny list if (payload.jti) { const isRevoked = await this.denyList.isRevoked(payload.jti); if (isRevoked) { throw new TokenError('Token has been revoked'); } } // Step 3: Check token version if (payload.sub) { const isValidVersion = await this.versionValidator.isValidVersion( payload.sub, payload.tokenVersion ); if (!isValidVersion) { throw new TokenError('Token version is outdated'); } } return payload; }}Production systems typically combine strategies: short access token lifetimes (primary), deny list for emergency revocation (security incidents), and token versioning for user-initiated actions (password change, logout all). The deny list only needs to store entries until the token would expire naturally.
Token validation should be centralized in middleware—consistent, tested, and applied uniformly across protected routes. Here's a production-grade implementation pattern:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
// Production Authentication Middlewareimport * as jose from 'jose';import { Request, Response, NextFunction } from 'express'; interface AuthConfig { issuer: string; audience: string; jwksUri: string; algorithms: string[]; clockToleranceSeconds: number; tokenLookupFn?: (req: Request) => string | undefined;} interface AuthenticatedRequest extends Request { auth: { token: string; payload: jose.JWTPayload; scopes: string[]; userId: string; };} class AuthMiddleware { private jwks: jose.JWTVerifyGetKey; constructor(private config: AuthConfig) { this.jwks = jose.createRemoteJWKSet(new URL(config.jwksUri)); } // Extract token from request private extractToken(req: Request): string | undefined { // Custom extraction function if (this.config.tokenLookupFn) { return this.config.tokenLookupFn(req); } // Standard: Authorization header const authHeader = req.headers.authorization; if (authHeader?.startsWith('Bearer ')) { return authHeader.slice(7); } // Fallback: Query parameter (less secure, use sparingly) if (req.query.access_token) { return req.query.access_token as string; } return undefined; } // Main authentication middleware authenticate() { return async (req: Request, res: Response, next: NextFunction) => { try { const token = this.extractToken(req); if (!token) { return res.status(401).json({ error: 'missing_token', error_description: 'No access token provided', }); } // Verify token const { payload } = await jose.jwtVerify(token, this.jwks, { issuer: this.config.issuer, audience: this.config.audience, algorithms: this.config.algorithms as any[], clockTolerance: this.config.clockToleranceSeconds, }); // Optional: Additional revocation checks // await this.checkRevocation(payload); // Attach to request (req as AuthenticatedRequest).auth = { token, payload, scopes: (payload.scope as string || '').split(' ').filter(Boolean), userId: payload.sub as string, }; next(); } catch (error) { if (error instanceof jose.errors.JWTExpired) { return res.status(401).json({ error: 'token_expired', error_description: 'Access token has expired', }); } if (error instanceof jose.errors.JWTInvalid) { return res.status(401).json({ error: 'invalid_token', error_description: 'Access token is invalid', }); } if (error instanceof jose.errors.JWTClaimValidationFailed) { return res.status(401).json({ error: 'invalid_claims', error_description: error.message, }); } console.error('Auth error:', error); return res.status(401).json({ error: 'authentication_error', error_description: 'Unable to authenticate request', }); } }; } // Scope checking middleware requireScopes(...requiredScopes: string[]) { return (req: Request, res: Response, next: NextFunction) => { const authReq = req as AuthenticatedRequest; if (!authReq.auth) { return res.status(401).json({ error: 'unauthenticated', error_description: 'Authentication required', }); } const hasScopes = requiredScopes.every( scope => authReq.auth.scopes.includes(scope) ); if (!hasScopes) { return res.status(403).json({ error: 'insufficient_scope', error_description: `Required scopes: ${requiredScopes.join(', ')}`, required_scopes: requiredScopes, }); } next(); }; } // Optional authentication (doesn't fail if no token) optionalAuth() { return async (req: Request, res: Response, next: NextFunction) => { const token = this.extractToken(req); if (!token) { return next(); // Continue without auth } try { const { payload } = await jose.jwtVerify(token, this.jwks, { issuer: this.config.issuer, audience: this.config.audience, algorithms: this.config.algorithms as any[], clockTolerance: this.config.clockToleranceSeconds, }); (req as AuthenticatedRequest).auth = { token, payload, scopes: (payload.scope as string || '').split(' ').filter(Boolean), userId: payload.sub as string, }; } catch { // Invalid token on optional route - continue unauthenticated } next(); }; }} // Usageconst auth = new AuthMiddleware({ issuer: 'https://auth.example.com', audience: 'my-api', jwksUri: 'https://auth.example.com/.well-known/jwks.json', algorithms: ['RS256'], clockToleranceSeconds: 30,}); // Protected routeapp.get('/api/protected', auth.authenticate(), (req, res) => { const authReq = req as AuthenticatedRequest; res.json({ userId: authReq.auth.userId }); }); // Route with specific scope requirementapp.delete('/api/users/:id', auth.authenticate(), auth.requireScopes('users:delete', 'admin'), (req, res) => { // Has both users:delete AND admin scopes }); // Optional auth route (works with or without token)app.get('/api/content', auth.optionalAuth(), (req, res) => { const authReq = req as AuthenticatedRequest; const isAuthenticated = !!authReq.auth; // Return different content based on auth status });Use standardized OAuth error responses (error, error_description) for consistency. This helps clients handle auth failures uniformly. Include helpful descriptions for debugging but never expose internal details that could aid attackers.
Even experienced engineers make token validation mistakes. Here are the most common pitfalls and how to avoid them:
One of the most common security vulnerabilities is treating token decoding as validation. Code like jwt.decode(token) in Python or jose.decodeJwt(token) in JavaScript only base64-decodes the payload—it does NOT verify the signature. Attackers can trivially create tokens with any claims they want. ALWAYS use jwt.verify() or equivalent with proper key material.
Token validation is where your application's security meets reality. We've covered comprehensive validation from cryptographic verification to business logic enforcement:
What's Next:
We've covered OAuth 2.0 flows, token management, OIDC identity, and token validation. The final page brings it all together with common implementation patterns—how real systems like Google, GitHub, and enterprise platforms implement OAuth2/OIDC at scale.
You now have deep expertise in token validation—the gatekeeper function that protects your entire application. You can implement signature verification, claims validation, scope enforcement, and revocation checking. You know the common pitfalls and how to avoid them. Next, we explore common implementation patterns used by industry leaders.