Loading content...
OAuth 2.0 and JSON Web Tokens (JWT) have become the dominant standards for API authentication and authorization. Understanding how to properly validate tokens at the gateway layer is essential for building secure, standards-compliant systems.
This page provides a deep exploration of:
While OAuth2 and JWT are often discussed together, they serve different purposes:
| Standard | Purpose | What It Defines |
|---|---|---|
| OAuth 2.0 | Authorization Framework | How to obtain tokens, token types, grant flows |
| JWT | Token Format | How tokens are structured and signed |
| OpenID Connect | Identity Layer | How to verify user identity (builds on OAuth2) |
At the gateway, we typically receive JWTs issued via OAuth2 flows and must validate them before allowing access to backend services.
This page focuses on token validation at the gateway—what to check, how to check it, and what can go wrong. We assume tokens have already been issued by an identity provider (IdP). The flows for obtaining tokens (authorization code, client credentials, etc.) are covered in specialized OAuth2 resources.
A JSON Web Token (JWT, pronounced "jot") is a compact, URL-safe means of representing claims between two parties. Understanding its structure is fundamental to proper validation.
A JWT consists of three Base64URL-encoded parts separated by dots:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0yMDI0LTAxIn0.
eyJpc3MiOiJodHRwczovL2F1dGguZXhhbXBsZS5jb20iLCJzdWIiOiJ1c2VyLTEyMzQ1Iiwi
YXVkIjoiYXBpLmV4YW1wbGUuY29tIiwiaWF0IjoxNzA0MDY3MjAwLCJleHAiOjE3MDQwNzA4
MDAsInNjb3BlIjoicmVhZDphY2NvdW50cyB3cml0ZTp0cmFuc2ZlcnMifQ.
kWbxZQk0Ep5...signature...
Part 1: Header — Metadata about the token Part 2: Payload — The actual claims (data) Part 3: Signature — Cryptographic verification
123456789101112131415161718192021222324252627282930313233343536
// HEADER (Base64URL decoded){ "alg": "RS256", // Algorithm used for signing "typ": "JWT", // Token type "kid": "key-2024-01" // Key ID for signature verification} // PAYLOAD (Base64URL decoded){ // Registered claims (standardized by RFC 7519) "iss": "https://auth.example.com", // Issuer "sub": "user-12345", // Subject (who this token is about) "aud": "api.example.com", // Audience (who should accept this token) "iat": 1704067200, // Issued At (Unix timestamp) "exp": 1704070800, // Expiration Time (Unix timestamp) "nbf": 1704067200, // Not Before (Unix timestamp) "jti": "a1b2c3d4-e5f6-7890-abcd", // JWT ID (unique identifier) // Public claims (registered with IANA, optional) "email": "user@example.com", "email_verified": true, "name": "Jane Doe", // Private claims (application-specific) "scope": "read:accounts write:transfers", "tenant_id": "tenant-abc", "roles": ["user", "premium"], "permissions": ["accounts.read", "transfers.create"]} // SIGNATURE// Created by signing the header and payload with the private key:// signature = RS256(// base64UrlEncode(header) + "." + base64UrlEncode(payload),// privateKey// )alg (Algorithm): Specifies the cryptographic algorithm used to sign the token. The gateway must verify this matches an expected algorithm.
| Algorithm | Type | Description |
|---|---|---|
| RS256 | Asymmetric | RSA with SHA-256 (most common) |
| RS384, RS512 | Asymmetric | RSA with SHA-384/512 |
| ES256 | Asymmetric | ECDSA with P-256 curve |
| ES384, ES512 | Asymmetric | ECDSA with P-384/P-521 |
| PS256 | Asymmetric | RSA-PSS with SHA-256 |
| HS256 | Symmetric | HMAC-SHA256 (shared secret) |
| none | None | No signature (DANGEROUS) |
kid (Key ID): Identifies which key from the JWKS (JSON Web Key Set) should be used to verify the signature. Essential for key rotation.
typ (Type): Usually "JWT". Can be "at+jwt" for access tokens per RFC 9068.
The 'none' algorithm indicates an unsigned token. Attackers can modify a legitimate token, change 'alg' to 'none', remove the signature, and bypass authentication. Always explicitly whitelist allowed algorithms and reject 'none'.
Beyond signature verification, the gateway must validate that the token's claims are appropriate for the request. Each registered claim serves a specific security purpose.
1. exp (Expiration Time)
The token must not be used after this time. Expiration prevents indefinite token validity and limits the window for stolen token abuse.
12345678910111213141516171819202122232425262728293031323334
function validateExpiration(claims: JwtPayload, clockSkewSeconds: number = 30): void { const now = Math.floor(Date.now() / 1000); if (!claims.exp) { // Tokens without expiration are dangerous - reject by default throw new JwtValidationError( "missing_expiration", "Token has no expiration claim" ); } // Allow small clock skew between issuer and validator const expirationWithSkew = claims.exp + clockSkewSeconds; if (now > expirationWithSkew) { throw new JwtValidationError( "token_expired", `Token expired at ${new Date(claims.exp * 1000).toISOString()}`, { expiredAt: claims.exp, serverTime: now } ); }} // Production configurationconst CLOCK_SKEW_CONFIG = { // Standard clock skew tolerance default: 30, // 30 seconds // For high-security environments, reduce tolerance highSecurity: 5, // 5 seconds // Maximum safe skew (beyond this indicates clock issues) maximum: 60, // 1 minute};2. iss (Issuer)
The issuer claim identifies who created and signed the token. Validate this exactly matches your expected identity provider(s).
12345678910111213141516171819202122232425262728
function validateIssuer(claims: JwtPayload, allowedIssuers: string[]): void { if (!claims.iss) { throw new JwtValidationError( "missing_issuer", "Token has no issuer claim" ); } // Exact match required - no partial matching or regex! if (!allowedIssuers.includes(claims.iss)) { throw new JwtValidationError( "invalid_issuer", `Issuer '${claims.iss}' is not trusted`, { received: claims.iss, allowed: allowedIssuers } ); }} // Configuration example: Multiple issuers for migration or federationconst ALLOWED_ISSUERS = [ "https://auth.example.com", // Primary IdP "https://auth-eu.example.com", // Regional IdP "https://partner-auth.example.com", // Federated partner (if applicable)]; // WARNING: Never accept issuer URLs with trailing slashes inconsistently// "https://auth.example.com" !== "https://auth.example.com/"// Normalize in configuration or reject inconsistent tokens3. aud (Audience)
The audience claim specifies the intended recipient of the token. This prevents tokens issued for one service from being used to access another.
123456789101112131415161718192021222324252627282930313233
function validateAudience(claims: JwtPayload, expectedAudience: string | string[]): void { if (!claims.aud) { throw new JwtValidationError( "missing_audience", "Token has no audience claim" ); } // Audience can be a string or array of strings const tokenAudiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; const requiredAudiences = Array.isArray(expectedAudience) ? expectedAudience : [expectedAudience]; // Token must contain at least one expected audience const hasValidAudience = tokenAudiences.some(aud => requiredAudiences.includes(aud) ); if (!hasValidAudience) { throw new JwtValidationError( "invalid_audience", "Token is not intended for this service", { received: tokenAudiences, expected: requiredAudiences } ); }} // API Gateway configurationconst GATEWAY_AUDIENCE = [ "api.example.com", // General API audience "https://api.example.com", // URL format (some IdPs use this)];4. nbf (Not Before)
Optional but important—the token must not be accepted before this time. Prevents pre-generated tokens from being used early.
12345678910111213141516171819
function validateNotBefore(claims: JwtPayload, clockSkewSeconds: number = 30): void { if (!claims.nbf) { // nbf is optional - if not present, token is valid immediately upon issuance return; } const now = Math.floor(Date.now() / 1000); // Allow small clock skew const notBeforeWithSkew = claims.nbf - clockSkewSeconds; if (now < notBeforeWithSkew) { throw new JwtValidationError( "token_not_yet_valid", `Token is not valid until ${new Date(claims.nbf * 1000).toISOString()}`, { validFrom: claims.nbf, serverTime: now } ); }}Validate claims in a logical order: signature → expiration → issuer → audience → custom claims. If the signature is invalid, there's no point checking claims. If the token is expired, reject it before more expensive checks.
JSON Web Key Set (JWKS) is a standard format for publishing public keys used to verify JWT signatures. Proper JWKS handling is critical for secure, maintainable token validation.
Identity providers expose a JWKS endpoint (typically at /.well-known/jwks.json) containing their public signing keys. The gateway fetches and caches these keys for signature verification.
1234567891011121314151617181920212223242526272829
{ "keys": [ { "kty": "RSA", // Key Type "use": "sig", // Usage: signature (vs encryption) "alg": "RS256", // Algorithm "kid": "key-2024-01", // Key ID (matches JWT header 'kid') "n": "0vx7agoebGcQSuuP...", // RSA modulus (Base64URL) "e": "AQAB" // RSA exponent (Base64URL) }, { "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "key-2024-02", // Second key for rotation "n": "1b8mMNUYMdKQ...", "e": "AQAB" }, { "kty": "EC", // Elliptic Curve key "use": "sig", "alg": "ES256", "kid": "ec-key-2024-01", "crv": "P-256", // Curve name "x": "f83OJ3D2xF1Bg8vub9tLe...", // X coordinate "y": "x_FEzRu9m36HLN_tue659..." // Y coordinate } ]}The gateway must efficiently retrieve and cache JWKS to balance security (using current keys) with performance (avoiding network calls per request).
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176
interface JwksCacheConfig { // How long to cache JWKS (seconds) cacheTtl: number; // When to proactively refresh (percentage of TTL) refreshThreshold: number; // e.g., 0.75 = refresh at 75% of TTL // Maximum stale cache age during failures (seconds) staleMaxAge: number; // HTTP client settings fetchTimeout: number; retryAttempts: number; retryDelay: number;} const PRODUCTION_CONFIG: JwksCacheConfig = { cacheTtl: 3600, // 1 hour cache refreshThreshold: 0.75, // Refresh after 45 minutes staleMaxAge: 86400, // Allow 24-hour stale cache in emergencies fetchTimeout: 5000, // 5 second fetch timeout retryAttempts: 3, retryDelay: 1000,}; class JwksClient { private cache = new Map<string, JwksCacheEntry>(); private refreshPromises = new Map<string, Promise<void>>(); constructor(private config: JwksCacheConfig) {} async getKey(issuer: string, kid: string): Promise<crypto.KeyObject> { const cacheKey = issuer; let entry = this.cache.get(cacheKey); // Cache miss: must fetch if (!entry) { await this.fetchJwks(issuer); entry = this.cache.get(cacheKey)!; } // Cache hit but stale: fetch synchronously else if (this.isExpired(entry)) { await this.fetchJwks(issuer); entry = this.cache.get(cacheKey)!; } // Cache hit, nearing expiry: background refresh else if (this.shouldRefresh(entry)) { this.backgroundRefresh(issuer); } // Look up key by ID const key = entry.keys.get(kid); if (!key) { // Key not found - maybe it was just added? // Try one refresh if we haven't recently if (!entry.recentlyRefreshed) { await this.fetchJwks(issuer); entry = this.cache.get(cacheKey)!; entry.recentlyRefreshed = true; const retryKey = entry.keys.get(kid); if (retryKey) return retryKey; } throw new JwksError( "unknown_key", `Key ID '${kid}' not found in JWKS for issuer '${issuer}'` ); } return key; } private async fetchJwks(issuer: string): Promise<void> { // Coalesce concurrent requests let existingPromise = this.refreshPromises.get(issuer); if (existingPromise) { return existingPromise; } const fetchPromise = this.doFetch(issuer); this.refreshPromises.set(issuer, fetchPromise); try { await fetchPromise; } finally { this.refreshPromises.delete(issuer); } } private async doFetch(issuer: string): Promise<void> { const jwksUri = await this.discoverJwksUri(issuer); let lastError: Error | null = null; for (let attempt = 0; attempt < this.config.retryAttempts; attempt++) { try { const response = await fetch(jwksUri, { signal: AbortSignal.timeout(this.config.fetchTimeout), headers: { "Accept": "application/json", "User-Agent": "APIGateway/1.0", }, }); if (!response.ok) { throw new Error(`JWKS fetch failed: HTTP ${response.status}`); } const jwks = await response.json(); this.cacheJwks(issuer, jwks); return; } catch (error) { lastError = error as Error; if (attempt < this.config.retryAttempts - 1) { await this.delay(this.config.retryDelay * (attempt + 1)); } } } // All retries failed - check if we have stale cache const staleEntry = this.cache.get(issuer); if (staleEntry && this.isUsableStale(staleEntry)) { console.warn(`JWKS refresh failed for ${issuer}, using stale cache`); staleEntry.stale = true; this.emitMetric("jwks.stale_cache_used", { issuer }); return; } throw new JwksError( "fetch_failed", `Failed to fetch JWKS from ${issuer}: ${lastError?.message}` ); } private async discoverJwksUri(issuer: string): Promise<string> { // Try OIDC discovery first const discoveryUrl = `${issuer}/.well-known/openid-configuration`; try { const response = await fetch(discoveryUrl, { signal: AbortSignal.timeout(this.config.fetchTimeout), }); if (response.ok) { const config = await response.json(); return config.jwks_uri; } } catch { // Discovery failed, fall back to standard location } // Standard JWKS location return `${issuer}/.well-known/jwks.json`; } private cacheJwks(issuer: string, jwks: any): void { const keys = new Map<string, crypto.KeyObject>(); for (const jwk of jwks.keys || []) { if (!jwk.kid) continue; if (jwk.use && jwk.use !== "sig") continue; // Only signing keys const keyObject = crypto.createPublicKey({ key: jwk, format: "jwk" }); keys.set(jwk.kid, keyObject); } this.cache.set(issuer, { keys, fetchedAt: Date.now(), expiresAt: Date.now() + (this.config.cacheTtl * 1000), stale: false, recentlyRefreshed: false, }); }}Identity providers periodically rotate signing keys for security. The gateway must handle rotation gracefully without causing authentication outages.
kid Handling: When encountering an unknown key ID, trigger an immediate JWKS refresh before rejecting the token.The signature is the cryptographic guarantee that the token was issued by a trusted party and hasn't been tampered with. Proper signature verification is the foundation of JWT security.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
const ALLOWED_ALGORITHMS = new Set(["RS256", "RS384", "RS512", "ES256", "ES384"]); interface ValidationResult { valid: boolean; claims: JwtPayload | null; error: JwtValidationError | null;} async function verifyJwtSignature( token: string, jwksClient: JwksClient, expectedIssuer: string): Promise<ValidationResult> { // Step 1: Parse token structure const parts = token.split("."); if (parts.length !== 3) { return { valid: false, claims: null, error: new JwtValidationError( "malformed_token", "JWT must have three parts separated by dots" ), }; } const [encodedHeader, encodedPayload, encodedSignature] = parts; // Step 2: Decode header (do NOT verify yet) let header: JwtHeader; try { header = JSON.parse(base64UrlDecode(encodedHeader)); } catch { return { valid: false, claims: null, error: new JwtValidationError( "invalid_header", "JWT header is not valid JSON" ), }; } // Step 3: Validate algorithm BEFORE any cryptographic operations if (!header.alg) { return { valid: false, claims: null, error: new JwtValidationError( "missing_algorithm", "JWT header missing 'alg' claim" ), }; } if (header.alg.toLowerCase() === "none") { // Critical security check return { valid: false, claims: null, error: new JwtValidationError( "forbidden_algorithm", "Algorithm 'none' is not allowed" ), }; } if (!ALLOWED_ALGORITHMS.has(header.alg)) { return { valid: false, claims: null, error: new JwtValidationError( "unsupported_algorithm", `Algorithm '${header.alg}' is not in allowed list`, { allowed: Array.from(ALLOWED_ALGORITHMS) } ), }; } // Step 4: Verify key ID is present if (!header.kid) { return { valid: false, claims: null, error: new JwtValidationError( "missing_key_id", "JWT header missing 'kid' claim" ), }; } // Step 5: Retrieve public key let publicKey: crypto.KeyObject; try { publicKey = await jwksClient.getKey(expectedIssuer, header.kid); } catch (error) { return { valid: false, claims: null, error: new JwtValidationError( "key_not_found", `Cannot find key '${header.kid}' for issuer '${expectedIssuer}'` ), }; } // Step 6: Verify signature const signedContent = `${encodedHeader}.${encodedPayload}`; const signature = base64UrlDecodeToBuffer(encodedSignature); let isValid: boolean; try { isValid = crypto.verify( getHashAlgorithm(header.alg), Buffer.from(signedContent), publicKey, signature ); } catch (error) { return { valid: false, claims: null, error: new JwtValidationError( "verification_error", "Cryptographic verification failed" ), }; } if (!isValid) { return { valid: false, claims: null, error: new JwtValidationError( "invalid_signature", "JWT signature verification failed" ), }; } // Step 7: Decode and return payload let payload: JwtPayload; try { payload = JSON.parse(base64UrlDecode(encodedPayload)); } catch { return { valid: false, claims: null, error: new JwtValidationError( "invalid_payload", "JWT payload is not valid JSON" ), }; } return { valid: true, claims: payload, error: null };} function getHashAlgorithm(jwtAlg: string): string { const mapping: Record<string, string> = { RS256: "sha256", RS384: "sha384", RS512: "sha512", ES256: "sha256", ES384: "sha384", ES512: "sha512", PS256: "sha256", PS384: "sha384", PS512: "sha512", }; return mapping[jwtAlg] || "sha256";}Never trust the algorithm from the token header without validation. A classic attack involves changing RS256 (public/private key) to HS256 (shared secret) and using the public key as the HMAC secret. Since the public key is known, the attacker can forge valid signatures. Always validate against an explicit allowlist.
OpenID Connect (OIDC) is an identity layer built on OAuth 2.0. While OAuth2 provides authorization tokens (access tokens), OIDC adds identity verification through ID tokens. Understanding the distinction is important for gateway validation.
| Aspect | Access Token | ID Token |
|---|---|---|
| Purpose | Authorize API access | Prove user identity |
| Intended Audience | Resource server (API) | Client application |
| Typical Use at Gateway | Validate for API calls | Extract user info (if needed) |
| Required Claims | iss, exp, aud | iss, sub, aud, exp, iat |
| Contains User Info | Usually minimal | Detailed identity claims |
At the gateway, you'll primarily validate access tokens. ID tokens may be used for SSO scenarios or when extracting detailed user information.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354
interface OidcConfiguration { issuer: string; authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string; jwks_uri: string; response_types_supported: string[]; subject_types_supported: string[]; id_token_signing_alg_values_supported: string[]; scopes_supported: string[]; claims_supported: string[];} class OidcClient { private configCache = new Map<string, OidcConfiguration>(); async discover(issuer: string): Promise<OidcConfiguration> { const cached = this.configCache.get(issuer); if (cached) return cached; // Standard OIDC discovery URL const discoveryUrl = new URL("/.well-known/openid-configuration", issuer); const response = await fetch(discoveryUrl.toString(), { headers: { "Accept": "application/json" }, signal: AbortSignal.timeout(5000), }); if (!response.ok) { throw new OidcError( "discovery_failed", `OIDC discovery failed for ${issuer}: HTTP ${response.status}` ); } const config = await response.json() as OidcConfiguration; // Validate issuer matches (security requirement per spec) if (config.issuer !== issuer) { throw new OidcError( "issuer_mismatch", `Discovered issuer '${config.issuer}' doesn't match expected '${issuer}'` ); } this.configCache.set(issuer, config); return config; } async getJwksUri(issuer: string): Promise<string> { const config = await this.discover(issuer); return config.jwks_uri; }}When the gateway needs to validate ID tokens (e.g., for SSO or user info extraction), additional validations apply:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
async function validateIdToken( token: string, expectedClientId: string, expectedNonce?: string): Promise<IdTokenClaims> { // Standard JWT validation first const { valid, claims, error } = await verifyJwtSignature( token, jwksClient, expectedIssuer ); if (!valid) throw error; // ID token specific validations // 1. 'sub' (Subject) is required if (!claims.sub) { throw new JwtValidationError( "missing_subject", "ID token must contain 'sub' claim" ); } // 2. 'iat' (Issued At) is required if (!claims.iat) { throw new JwtValidationError( "missing_iat", "ID token must contain 'iat' claim" ); } // 3. Audience must contain the client ID const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud]; if (!audiences.includes(expectedClientId)) { throw new JwtValidationError( "invalid_audience", "ID token audience doesn't include expected client ID" ); } // 4. If audience has multiple values, 'azp' (Authorized Party) must be present if (audiences.length > 1) { if (!claims.azp || claims.azp !== expectedClientId) { throw new JwtValidationError( "invalid_azp", "Multi-audience ID token must have 'azp' matching client ID" ); } } // 5. Nonce validation (for authorization code flow) if (expectedNonce) { if (claims.nonce !== expectedNonce) { throw new JwtValidationError( "nonce_mismatch", "ID token nonce doesn't match expected value" ); } } // 6. Check token age (ID tokens should be recent) const maxAge = 300; // 5 minutes const age = Math.floor(Date.now() / 1000) - claims.iat; if (age > maxAge) { throw new JwtValidationError( "id_token_too_old", `ID token was issued ${age} seconds ago, max allowed is ${maxAge}` ); } return claims as IdTokenClaims;}Not all OAuth2 implementations use JWTs. Some issue opaque (reference) tokens that require introspection—a real-time call to the authorization server to validate the token and retrieve its associated claims.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
interface IntrospectionResponse { active: boolean; // Only present if active=true scope?: string; client_id?: string; username?: string; token_type?: string; exp?: number; iat?: number; nbf?: number; sub?: string; aud?: string | string[]; iss?: string; jti?: string; // Extension claims [key: string]: unknown;} class TokenIntrospectionClient { private responseCache: LRUCache<string, IntrospectionResponse>; private circuitBreaker: CircuitBreaker; constructor( private introspectionEndpoint: string, private clientId: string, private clientSecret: string, config: IntrospectionConfig ) { this.responseCache = new LRUCache({ max: config.cacheMaxSize || 10000, ttl: config.cacheTtl || 30000, // 30 seconds }); this.circuitBreaker = new CircuitBreaker({ failureThreshold: 5, successThreshold: 2, timeout: 30000, }); } async introspect(token: string): Promise<IntrospectionResponse> { // Check cache first (hash token for key) const cacheKey = this.hashToken(token); const cached = this.responseCache.get(cacheKey); if (cached) { return cached; } // Check circuit breaker if (this.circuitBreaker.isOpen()) { throw new IntrospectionError( "circuit_open", "Introspection endpoint is currently unavailable" ); } try { const response = await this.doIntrospect(token); // Cache the response if (response.active) { this.responseCache.set(cacheKey, response); } this.circuitBreaker.recordSuccess(); return response; } catch (error) { this.circuitBreaker.recordFailure(); throw error; } } private async doIntrospect(token: string): Promise<IntrospectionResponse> { // RFC 7662: POST with form-encoded body const body = new URLSearchParams({ token: token, token_type_hint: "access_token", }); const response = await fetch(this.introspectionEndpoint, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", "Authorization": `Basic ${this.encodeCredentials()}`, "Accept": "application/json", }, body: body.toString(), signal: AbortSignal.timeout(1000), // Fast timeout for critical path }); if (!response.ok) { throw new IntrospectionError( "introspection_failed", `Introspection returned HTTP ${response.status}` ); } const result = await response.json() as IntrospectionResponse; return result; } private hashToken(token: string): string { // Use first 32 chars of SHA-256 for cache key (don't store full token) return crypto.createHash("sha256").update(token).digest("hex").slice(0, 32); } private encodeCredentials(): string { return Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64"); }}| Aspect | JWT Validation | Token Introspection |
|---|---|---|
| Token Format | Self-contained JWT | Opaque string or any format |
| Validation Location | Local (gateway) | Remote (authorization server) |
| Network Dependency | Only for JWKS refresh | Every validation |
| Latency | ~1ms | 10-100ms |
| Revocation | Only at token expiry | Immediate (real-time) |
| Offline Capability | Yes (with cached JWKS) | No |
| Token Size | Larger (contains claims) | Smaller (reference only) |
Many systems use a hybrid approach: validate JWTs locally for performance, but check a revocation list or call introspection for high-risk operations (e.g., financial transactions, admin actions). This balances performance with security requirements.
Bringing together all the concepts, here's a production-grade validation pipeline that handles both JWTs and opaque tokens, with proper error handling and observability.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
interface AuthResult { authenticated: boolean; identity: { subject: string; issuer: string; email?: string; scopes: string[]; claims: Record<string, unknown>; } | null; error: { code: string; message: string; details?: Record<string, unknown>; } | null; tokenType: "jwt" | "opaque" | null; validationDurationMs: number;} class TokenValidator { constructor( private jwksClient: JwksClient, private introspectionClient: TokenIntrospectionClient, private config: TokenValidationConfig ) {} async validate(token: string, request: Request): Promise<AuthResult> { const startTime = performance.now(); try { // Determine token type const tokenType = this.detectTokenType(token); let result: AuthResult; if (tokenType === "jwt") { result = await this.validateJwt(token); } else { result = await this.validateOpaque(token); } result.validationDurationMs = performance.now() - startTime; // Emit metrics this.emitMetrics(result, request); return result; } catch (error) { const duration = performance.now() - startTime; this.emitMetrics({ authenticated: false, identity: null, error: { code: "unexpected_error", message: (error as Error).message }, tokenType: null, validationDurationMs: duration, }, request); throw error; } } private detectTokenType(token: string): "jwt" | "opaque" { // JWTs have exactly 2 dots and Base64URL-encoded parts const parts = token.split("."); if (parts.length !== 3) { return "opaque"; } // Try to decode header as JSON try { const header = JSON.parse(base64UrlDecode(parts[0])); if (header.alg && header.typ) { return "jwt"; } } catch { return "opaque"; } return "opaque"; } private async validateJwt(token: string): Promise<AuthResult> { // Step 1: Signature verification const { valid, claims, error } = await verifyJwtSignature( token, this.jwksClient, this.config.expectedIssuer ); if (!valid) { return { authenticated: false, identity: null, error: { code: error!.code, message: error!.message }, tokenType: "jwt", validationDurationMs: 0, }; } // Step 2: Claims validation try { validateExpiration(claims!, this.config.clockSkewSeconds); validateIssuer(claims!, [this.config.expectedIssuer]); validateAudience(claims!, this.config.expectedAudience); validateNotBefore(claims!, this.config.clockSkewSeconds); // Custom validations if (this.config.requiredScopes.length > 0) { this.validateScopes(claims!, this.config.requiredScopes); } } catch (validationError) { return { authenticated: false, identity: null, error: { code: (validationError as JwtValidationError).code, message: (validationError as JwtValidationError).message, }, tokenType: "jwt", validationDurationMs: 0, }; } // Step 3: Build identity return { authenticated: true, identity: { subject: claims!.sub!, issuer: claims!.iss!, email: claims!.email as string | undefined, scopes: this.parseScopes(claims!.scope), claims: claims as Record<string, unknown>, }, error: null, tokenType: "jwt", validationDurationMs: 0, }; } private async validateOpaque(token: string): Promise<AuthResult> { const introspection = await this.introspectionClient.introspect(token); if (!introspection.active) { return { authenticated: false, identity: null, error: { code: "inactive_token", message: "Token is not active" }, tokenType: "opaque", validationDurationMs: 0, }; } // Validate issuer if present if (introspection.iss && introspection.iss !== this.config.expectedIssuer) { return { authenticated: false, identity: null, error: { code: "invalid_issuer", message: "Token issuer mismatch", }, tokenType: "opaque", validationDurationMs: 0, }; } return { authenticated: true, identity: { subject: introspection.sub!, issuer: introspection.iss || this.config.expectedIssuer, email: introspection.username, scopes: this.parseScopes(introspection.scope), claims: introspection as Record<string, unknown>, }, error: null, tokenType: "opaque", validationDurationMs: 0, }; } private validateScopes(claims: JwtPayload, required: string[]): void { const tokenScopes = this.parseScopes(claims.scope); const missing = required.filter(s => !tokenScopes.includes(s)); if (missing.length > 0) { throw new JwtValidationError( "insufficient_scope", `Missing required scopes: ${missing.join(", ")}`, { required, present: tokenScopes } ); } } private parseScopes(scope: unknown): string[] { if (typeof scope === "string") { return scope.split(/\s+/).filter(Boolean); } if (Array.isArray(scope)) { return scope.filter(s => typeof s === "string"); } return []; } private emitMetrics(result: AuthResult, request: Request): void { metrics.histogram("auth.validation.duration_ms", result.validationDurationMs, { token_type: result.tokenType || "unknown", success: String(result.authenticated), }); if (!result.authenticated) { metrics.increment("auth.validation.failure", { error_code: result.error?.code || "unknown", }); } }}OAuth2 and JWT are the foundation of modern API authentication. Proper validation at the gateway requires understanding both the standards and their security implications.
exp (expiration), iss (issuer), and aud (audience). These are the minimum security requirements.With OAuth2/JWT validation mastered, the next page explores API Keys—a simpler but still widely-used authentication mechanism, its security considerations, and when to prefer it over token-based authentication.