OAuth2 and OpenID Connect
Common Implementations
Read aloud is unavailable in this browser.
OAuth2/OIDC in the Real World
Theory meets practice at scale. While the OAuth 2.0 and OpenID Connect specifications provide the framework, how organizations actually implement these protocols varies significantly based on scale, security requirements, and user experience goals.
This page bridges the gap between specification and production. We'll examine how major identity providers implement OAuth2/OIDC, common architectural patterns for different scenarios, integration strategies with popular platforms, and the operational considerations that separate prototype implementations from production-grade systems.
Whether you're integrating with Google Sign-In, building an enterprise SSO solution, or designing your own authorization server, these patterns represent battle-tested approaches used at massive scale.
By the end of this page, you will understand integration patterns for major identity providers (Google, Microsoft, GitHub), enterprise SSO architectures, choosing and configuring authorization servers, and production deployment and operational considerations.
Major Identity Provider Patterns
The major identity providers—Google, Microsoft, Apple, GitHub—each implement OAuth2/OIDC with their own characteristics while adhering to the core specifications. Understanding their nuances is essential for smooth integration.
Common Elements Across Providers:
- Support for Authorization Code flow with PKCE
- OIDC-compliant ID tokens
- Standard discovery endpoints (/.well-known/openid-configuration)
- Refresh token support (often requiring specific scopes)
Provider-Specific Considerations:
| Provider | Issuer URL | Key Characteristics | Gotchas |
|---|---|---|---|
| https://accounts.google.com | Stable, excellent docs, free tier generous | Refresh tokens need access_type=offline, consent prompt required for first request | |
| Microsoft (Azure AD) | https://login.microsoftonline.com/{tenant}/v2.0 | Enterprise-focused, multi-tenant, B2C option | Complex tenant configuration, multiple endpoints for different scenarios |
| Apple | https://appleid.apple.com | Required for iOS apps, privacy-focused | Email relay hiding, JWT client secrets, limited refresh token lifetime |
| GitHub | https://github.com | Developer-focused, OAuth 2.0 only (no OIDC) | No ID token (user info via API), app vs OAuth app distinction |
| Auth0 | https://{tenant}.auth0.com | Full IdP as a service, extensive customization | Pricing at scale, custom domain setup complexity |
| Okta | https://{org}.okta.com | Enterprise SSO, workforce identity | Complex admin interface, similar pricing concerns at scale |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
// Provider-Specific Configuration Examplesinterface ProviderConfig { name: string; discoveryUrl: string; scopes: string[]; additionalParams?: Record<string, string>;} const providers: Record<string, ProviderConfig> = { google: { name: 'Google', discoveryUrl: 'https://accounts.google.com/.well-known/openid-configuration', scopes: ['openid', 'email', 'profile'], additionalParams: { access_type: 'offline', // Required for refresh tokens prompt: 'consent', // Force consent to get refresh token }, }, microsoft: { name: 'Microsoft', // Use 'common' for multi-tenant, or specific tenant ID discoveryUrl: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration', scopes: [ 'openid', 'email', 'profile', 'offline_access', // Microsoft requires this for refresh tokens ], additionalParams: { response_mode: 'query', // Or 'fragment' or 'form_post' }, }, apple: { name: 'Apple', discoveryUrl: 'https://appleid.apple.com/.well-known/openid-configuration', scopes: ['openid', 'email', 'name'], additionalParams: { response_mode: 'form_post', // Apple requires form_post }, }, github: { name: 'GitHub', // GitHub doesn't have OIDC discovery - manual configuration discoveryUrl: '', // Not available scopes: ['read:user', 'user:email'], // GitHub-specific scopes additionalParams: {}, }, auth0: { name: 'Auth0', // Replace 'your-tenant' with actual tenant discoveryUrl: 'https://your-tenant.auth0.com/.well-known/openid-configuration', scopes: ['openid', 'email', 'profile', 'offline_access'], additionalParams: { audience: 'https://your-api.example.com', // For API access }, },}; // GitHub OAuth (manual, not OIDC)class GitHubOAuth { private readonly authUrl = 'https://github.com/login/oauth/authorize'; private readonly tokenUrl = 'https://github.com/login/oauth/access_token'; private readonly userUrl = 'https://api.github.com/user'; constructor( private clientId: string, private clientSecret: string, private redirectUri: string, ) {} getAuthorizationUrl(state: string): string { const url = new URL(this.authUrl); url.searchParams.set('client_id', this.clientId); url.searchParams.set('redirect_uri', this.redirectUri); url.searchParams.set('scope', 'read:user user:email'); url.searchParams.set('state', state); return url.toString(); } async exchangeCode(code: string): Promise<{ access_token: string }> { const response = await fetch(this.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, body: JSON.stringify({ client_id: this.clientId, client_secret: this.clientSecret, code: code, redirect_uri: this.redirectUri, }), }); return response.json(); } async getUser(accessToken: string): Promise<any> { const response = await fetch(this.userUrl, { headers: { 'Authorization': `Bearer ${accessToken}` }, }); return response.json(); }}Major providers offer official SDKs (google-auth-library, @azure/msal, apple-signin) that handle provider-specific quirks automatically. These are maintained by the providers and updated when APIs change. Prefer official SDKs over generic OAuth libraries for provider-specific integrations.
Enterprise SSO Patterns
Enterprise Single Sign-On (SSO) has specific requirements beyond consumer OAuth: workforce identity management, compliance requirements, centralized access control, and integration with existing directory services.
Common Enterprise Patterns:
- •Identity Provider Federation — Connect enterprise IdP (Okta, Azure AD, Ping) to your app. Users authenticate with their corporate credentials.
- •SAML to OIDC Bridge — Legacy enterprise systems use SAML. Modern apps use OIDC. Bridge solutions translate between protocols.
- •B2B Multi-Tenant — Each customer organization connects their own IdP. Your app supports multiple federated identity sources.
- •Workforce Identity Platform — Centralized identity (Okta, Azure AD) with SCIM provisioning, lifecycle management, and access governance.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181
// Multi-Tenant Enterprise SSO Implementationinterface TenantConfig { tenantId: string; name: string; domain: string; // e.g., 'acme.com' ssoEnabled: boolean; oidcConfig?: { issuer: string; clientId: string; clientSecret: string; };} class EnterpriseSSOManager { constructor( private tenantStore: TenantStore, private defaultLoginUrl: string, ) {} // Determine auth method based on email domain async getAuthMethodForEmail(email: string): Promise<{ method: 'sso' | 'password'; config?: TenantConfig; }> { const domain = email.split('@')[1]; const tenant = await this.tenantStore.findByDomain(domain); if (tenant?.ssoEnabled && tenant.oidcConfig) { return { method: 'sso', config: tenant }; } return { method: 'password' }; } // Build SSO login URL for tenant buildSSOUrl(tenant: TenantConfig, state: string, nonce: string): string { if (!tenant.oidcConfig) { throw new Error('Tenant does not have SSO configured'); } const { issuer, clientId } = tenant.oidcConfig; // Fetch discovery document (in practice, cache this) const discoveryUrl = `${issuer}/.well-known/openid-configuration`; // Build auth URL - this would be async in practice const authUrl = new URL(`${issuer}/authorize`); authUrl.searchParams.set('client_id', clientId); authUrl.searchParams.set('redirect_uri', `${this.defaultLoginUrl}/sso/callback`); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('scope', 'openid email profile'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('nonce', nonce); return authUrl.toString(); } // Validate SSO callback for specific tenant async validateSSOCallback( tenantId: string, code: string, state: string, expectedState: string, expectedNonce: string ): Promise<{ userId: string; email: string; claims: any }> { if (state !== expectedState) { throw new Error('State mismatch - CSRF detected'); } const tenant = await this.tenantStore.findById(tenantId); if (!tenant?.oidcConfig) { throw new Error('Invalid tenant'); } // Exchange code for tokens const tokens = await this.exchangeCode(tenant, code); // Validate ID token const claims = await this.validateIdToken( tenant, tokens.id_token, expectedNonce ); return { userId: claims.sub, email: claims.email, claims, }; } private async exchangeCode(tenant: TenantConfig, code: string) { const { issuer, clientId, clientSecret } = tenant.oidcConfig!; const response = await fetch(`${issuer}/oauth/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, client_id: clientId, client_secret: clientSecret, redirect_uri: `${this.defaultLoginUrl}/sso/callback`, }), }); return response.json(); } private async validateIdToken( tenant: TenantConfig, idToken: string, expectedNonce: string ): Promise<any> { // In practice, use jose library with proper JWKS validation // This is simplified for illustration const { issuer, clientId } = tenant.oidcConfig!; // Proper validation would: // 1. Fetch JWKS from issuer // 2. Verify signature // 3. Validate iss, aud, exp, nonce return { sub: 'user-id', email: 'user@example.com' }; // Placeholder }} // SCIM Provisioning Integrationinterface SCIMUser { id: string; userName: string; emails: Array<{ value: string; primary: boolean }>; active: boolean; name?: { givenName?: string; familyName?: string }; groups?: Array<{ value: string; display: string }>;} class SCIMProvisioning { // Handle user creation from IdP async createUser(scimUser: SCIMUser, tenantId: string): Promise<void> { const primaryEmail = scimUser.emails.find(e => e.primary)?.value; await this.userStore.create({ externalId: scimUser.id, email: primaryEmail, firstName: scimUser.name?.givenName, lastName: scimUser.name?.familyName, tenantId, active: scimUser.active, syncedAt: new Date(), }); } // Handle user updates from IdP async updateUser(scimUser: SCIMUser, tenantId: string): Promise<void> { const user = await this.userStore.findByExternalId(scimUser.id, tenantId); if (user) { await this.userStore.update(user.id, { active: scimUser.active, syncedAt: new Date(), }); } } // Handle user deprovisioning async deleteUser(externalId: string, tenantId: string): Promise<void> { const user = await this.userStore.findByExternalId(externalId, tenantId); if (user) { // Soft delete or deactivate await this.userStore.update(user.id, { active: false, deactivatedAt: new Date(), }); // Revoke all sessions and tokens await this.sessionStore.revokeAllForUser(user.id); } }}JIT provisioning creates user accounts on first SSO login—simple but lacks lifecycle management. SCIM provisioning syncs users from the IdP continuously—complex but enables proper onboarding/offboarding workflows. Enterprise deployments increasingly require SCIM for compliance (instant deprovisioning when employees leave).
Authorization Server Options
When building your own applications that need to issue tokens (not just consume them from external IdPs), you need an authorization server. The decision between building, buying, or using managed services has significant implications.
Options Spectrum:
| Option | Examples | Pros | Cons | Best For |
|---|---|---|---|---|
| Managed SaaS | Auth0, Okta, Firebase Auth, AWS Cognito | No infrastructure, rapid setup, managed security updates | Vendor lock-in, pricing at scale, limited customization | Startups, rapid development, teams without identity expertise |
| Self-Hosted Open Source | Keycloak, Ory, Authelia, Authentik | Full control, no per-user fees, customizable | Operational burden, security responsibility, expertise required | Organizations with infrastructure teams, specific customization needs |
| Cloud Provider Native | AWS Cognito, Azure AD B2C, GCP Identity Platform | Integrated with cloud ecosystem, managed | Cloud lock-in, feature gaps, complex pricing | All-in on specific cloud provider |
| Build Custom | Framework-specific libraries | Complete control, exact feature set | Massive effort, security risk, maintenance burden | Only when truly unique requirements justify it |
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859
// Keycloak Integration Example (Self-Hosted)import Keycloak from 'keycloak-connect';import session from 'express-session'; // Keycloak configurationconst keycloakConfig = { realm: 'my-realm', 'auth-server-url': 'https://keycloak.example.com/', 'ssl-required': 'all', resource: 'my-app', // Client ID 'public-client': false, 'confidential-port': 0, credentials: { secret: process.env.KEYCLOAK_CLIENT_SECRET, },}; // Initialize Keycloakconst memoryStore = new session.MemoryStore();const keycloak = new Keycloak({ store: memoryStore }, keycloakConfig); // Session configurationapp.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: true, store: memoryStore,})); // Initialize Keycloak middlewareapp.use(keycloak.middleware()); // Protected routeapp.get('/api/protected', keycloak.protect(), // Requires authentication (req, res) => { const user = (req as any).kauth.grant.access_token.content; res.json({ message: 'Protected data', user: user.preferred_username, }); }); // Role-based protectionapp.get('/api/admin', keycloak.protect('realm:admin'), // Requires 'admin' role (req, res) => { res.json({ message: 'Admin only data' }); }); // Scope-based protectionapp.get('/api/reports', keycloak.protect('reports:read'), // Requires specific scope (req, res) => { res.json({ message: 'Reports data' }); });Building a custom authorization server seems simple—issue JWTs, validate credentials, done. In reality, you need password hashing (argon2/bcrypt), rate limiting, brute force protection, MFA, session management, token revocation, refresh token rotation, PKCE, and constant security updates. Unless you have dedicated security engineering, using proven solutions is almost always the right choice.
Backend for Frontend (BFF) Pattern
The Backend for Frontend (BFF) pattern is the modern best practice for SPAs consuming OAuth2/OIDC. Instead of handling tokens in the browser JavaScript, a server-side component manages tokens and uses session cookies with the SPA.
Why BFF:
SPAs face a fundamental challenge: nowhere truly secure to store tokens. localStorage is XSS-vulnerable. Memory is lost on refresh. The BFF solves this by keeping tokens server-side and using httpOnly cookies for session management.
Architecture:
[SPA] <--httpOnly cookie--> [BFF] <--access token--> [Resource APIs]
↓
[Authorization Server]
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
// Complete BFF Implementation for SPA Securityimport express from 'express';import session from 'express-session';import RedisStore from 'connect-redis';import { createClient } from 'redis';import * as jose from 'jose'; const app = express();const redis = createClient({ url: process.env.REDIS_URL }); // Session configuration with secure cookiesapp.use(session({ store: new RedisStore({ client: redis }), secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false, name: '__Host-session', // __Host- prefix ensures secure, same-origin cookies cookie: { httpOnly: true, // Not accessible to JavaScript secure: true, // HTTPS only sameSite: 'strict', // CSRF protection maxAge: 24 * 60 * 60 * 1000, // 24 hours path: '/', },})); // CSRF protectionconst csrfProtection = (req: Request, res: Response, next: NextFunction) => { const csrfToken = req.headers['x-csrf-token']; const sessionCsrf = (req.session as any).csrfToken; if (!csrfToken || csrfToken !== sessionCsrf) { return res.status(403).json({ error: 'Invalid CSRF token' }); } next();}; // BFF Login endpoint - initiates OAuth flowapp.get('/bff/auth/login', (req, res) => { const state = crypto.randomUUID(); const nonce = crypto.randomUUID(); // Store in session for validation on callback (req.session as any).oauthState = state; (req.session as any).oauthNonce = nonce; const authUrl = new URL('https://auth.example.com/authorize'); authUrl.searchParams.set('client_id', process.env.OAUTH_CLIENT_ID!); authUrl.searchParams.set('redirect_uri', 'https://app.example.com/bff/auth/callback'); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('scope', 'openid email profile offline_access'); authUrl.searchParams.set('state', state); authUrl.searchParams.set('nonce', nonce); res.redirect(authUrl.toString());}); // BFF Callback - handles OAuth callback, stores tokensapp.get('/bff/auth/callback', async (req, res) => { const { code, state } = req.query; // Validate state if (state !== (req.session as any).oauthState) { return res.status(403).send('State mismatch'); } try { // Exchange code for tokens const tokenResponse = await fetch('https://auth.example.com/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code: code as string, client_id: process.env.OAUTH_CLIENT_ID!, client_secret: process.env.OAUTH_CLIENT_SECRET!, redirect_uri: 'https://app.example.com/bff/auth/callback', }), }); const tokens = await tokenResponse.json(); // Validate ID token const jwks = jose.createRemoteJWKSet( new URL('https://auth.example.com/.well-known/jwks.json') ); const { payload } = await jose.jwtVerify(tokens.id_token, jwks, { issuer: 'https://auth.example.com', audience: process.env.OAUTH_CLIENT_ID!, }); // Validate nonce if (payload.nonce !== (req.session as any).oauthNonce) { throw new Error('Nonce mismatch'); } // Store tokens in session (server-side, not browser!) (req.session as any).tokens = { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: Date.now() + (tokens.expires_in * 1000), }; (req.session as any).user = { id: payload.sub, email: payload.email, name: payload.name, }; (req.session as any).csrfToken = crypto.randomUUID(); // Clear OAuth state delete (req.session as any).oauthState; delete (req.session as any).oauthNonce; // Redirect to app res.redirect('https://app.example.com'); } catch (error) { console.error('Auth callback error:', error); res.redirect('https://app.example.com/login?error=auth_failed'); }}); // BFF User Info - returns user info to SPAapp.get('/bff/auth/user', (req, res) => { if (!(req.session as any).user) { return res.status(401).json({ authenticated: false }); } res.json({ authenticated: true, user: (req.session as any).user, csrfToken: (req.session as any).csrfToken, });}); // BFF API Proxy - proxies requests to resource server with tokenapp.all('/bff/api/*', csrfProtection, async (req, res) => { const session = req.session as any; if (!session.tokens) { return res.status(401).json({ error: 'Not authenticated' }); } // Refresh token if needed if (Date.now() > session.tokens.expiresAt - 60000) { try { const refreshed = await refreshTokens(session.tokens.refreshToken); session.tokens = { accessToken: refreshed.access_token, refreshToken: refreshed.refresh_token || session.tokens.refreshToken, expiresAt: Date.now() + (refreshed.expires_in * 1000), }; } catch { return res.status(401).json({ error: 'Session expired' }); } } // Forward request to resource server const apiPath = req.path.replace('/bff/api', ''); const apiResponse = await fetch(`https://api.example.com${apiPath}`, { method: req.method, headers: { 'Authorization': `Bearer ${session.tokens.accessToken}`, 'Content-Type': 'application/json', }, body: ['GET', 'HEAD'].includes(req.method) ? undefined : JSON.stringify(req.body), }); res.status(apiResponse.status).json(await apiResponse.json());}); // BFF Logoutapp.post('/bff/auth/logout', csrfProtection, (req, res) => { req.session.destroy((err) => { if (err) { return res.status(500).json({ error: 'Logout failed' }); } res.clearCookie('__Host-session'); res.json({ success: true }); });});- •Tokens never reach browser — Access and refresh tokens stay server-side, protected from XSS
- •httpOnly cookies for sessions — Session cookie can't be read by JavaScript
- •CSRF protection included — SameSite=strict plus CSRF tokens for state-changing requests
- •Centralized token refresh — BFF handles refresh transparently; SPA doesn't manage token lifecycle
- •Secure cookie attributes — __Host- prefix, Secure, HttpOnly, SameSite=Strict
Major security guidance (OAuth 2.0 for Browser-Based Apps BCP, OWASP) now recommends the BFF pattern for SPAs. If you're building a new SPA with OAuth, implement BFF from the start. Retrofitting is harder but usually worthwhile for sensitive applications.
Microservices Token Patterns
Microservices architectures require careful token handling for inter-service communication. The primary patterns are token forwarding (pass-through) and token exchange (acquiring new tokens for downstream services).
Key Considerations:
- Should Service B trust a token that was issued to Service A?
- How do we maintain user context across service boundaries?
- How do services authenticate their own identity (not user context)?
| Pattern | How It Works | When to Use | Considerations |
|---|---|---|---|
| Token Forwarding | Pass user's token to downstream services | Simple chains, same trust domain | All services must validate same token; audience claims complex |
| Token Exchange (RFC 8693) | Exchange user token for service-specific token | Cross-domain, reduced scope | Requires token exchange endpoint; additional latency |
| Phantom Token | Opaque token at edge, JWT internally | Security at gateway, performance internally | Gateway does introspection; services get rich JWT |
| Service Mesh mTLS | Identity from certificate, not token | Zero-trust, Istio/Linkerd | Infrastructure handles auth; app focuses on authz |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124
// Pattern 1: Token Forwardingclass ServiceAClient { async callServiceB(userAccessToken: string): Promise<any> { // Forward the user's token to downstream service return fetch('https://service-b.internal/api/resource', { headers: { 'Authorization': `Bearer ${userAccessToken}`, }, }); }} // Pattern 2: Token Exchange (RFC 8693)class TokenExchangeClient { constructor( private tokenEndpoint: string, private clientId: string, private clientSecret: string, ) {} async exchange( subjectToken: string, targetAudience: string, scopes: string[], ): Promise<string> { const response = await fetch(this.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', subject_token: subjectToken, subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', requested_token_type: 'urn:ietf:params:oauth:token-type:access_token', audience: targetAudience, scope: scopes.join(' '), client_id: this.clientId, client_secret: this.clientSecret, }), }); const data = await response.json(); return data.access_token; }} // Usage: Gateway service exchanges token for internal servicesclass APIGateway { private tokenExchange: TokenExchangeClient; async handleRequest(userToken: string, targetService: string) { // Exchange user's token for service-specific token const serviceToken = await this.tokenExchange.exchange( userToken, `https://${targetService}.internal`, ['service:read'], ); // Call internal service with exchanged token return fetch(`https://${targetService}.internal/api/resource`, { headers: { 'Authorization': `Bearer ${serviceToken}` }, }); }} // Pattern 3: Service-to-Service with Client Credentialsclass InternalServiceClient { private accessToken: string | null = null; private tokenExpiry: number = 0; constructor( private tokenEndpoint: string, private clientId: string, private clientSecret: string, private targetAudience: string, ) {} private async ensureToken(): Promise<string> { if (this.accessToken && Date.now() < this.tokenExpiry - 60000) { return this.accessToken; } const response = await fetch(this.tokenEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: this.clientId, client_secret: this.clientSecret, audience: this.targetAudience, }), }); const data = await response.json(); this.accessToken = data.access_token; this.tokenExpiry = Date.now() + (data.expires_in * 1000); return this.accessToken; } async callService(path: string): Promise<any> { const token = await this.ensureToken(); return fetch(`${this.targetAudience}${path}`, { headers: { 'Authorization': `Bearer ${token}` }, }); }} // Combining User Context with Service Identityclass ServiceWithUserContext { private serviceClient: InternalServiceClient; async callWithUserContext(userToken: string, path: string) { // Get service-to-service token for authentication const serviceToken = await this.serviceClient.ensureToken(); // Pass both: service identity + user context return fetch(`https://downstream.internal${path}`, { headers: { 'Authorization': `Bearer ${serviceToken}`, // Service identity 'X-User-Token': userToken, // User context (validated downstream) }, }); }}Token forwarding is simpler but means downstream services trust tokens not intended for them. Token exchange creates properly-scoped tokens for each service but adds latency and complexity. For internal services in the same trust domain, forwarding is often acceptable. For cross-domain or sensitive operations, exchange is safer.
Production Deployment Considerations
Deploying OAuth2/OIDC in production requires careful attention to operational concerns that don't appear in development environments.
- •Secret Management — Client secrets in vault/secrets manager, not environment variables. Rotate secrets periodically without downtime.
- •Key Rotation — JWKS keys should rotate regularly. Ensure your validation handles key rotation (fetch fresh keys on kid mismatch).
- •High Availability — Auth failures = total outage. Run auth servers in HA mode, multiple regions, with proper failover.
- •Rate Limiting — Token endpoints are attack targets. Rate limit by IP, client_id, and user to prevent credential stuffing and DoS.
- •Monitoring & Alerting — Monitor auth success/failure rates, token issuance latency, and unusual patterns (spike in failures = potential attack).
- •Audit Logging — Log authentication events for compliance and security investigation. Include user, client, IP, success/failure, and MFA method.
- •Backup & Recovery — How do you recover from auth server data loss? Test backup restoration regularly.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116
// Production-Ready OAuth Client Configurationinterface ProductionOAuthConfig { // Multiple authorization servers for failover authServers: Array<{ url: string; priority: number; healthCheck: string; }>; // Client credentials from secrets manager credentials: { provider: 'vault' | 'aws-secrets' | 'azure-keyvault'; path: string; }; // Resilience settings resilience: { timeout: number; retries: number; circuitBreaker: { failureThreshold: number; resetTimeout: number; }; }; // Observability observability: { metrics: boolean; tracing: boolean; auditLog: boolean; };} // Health check for auth serverclass AuthServerHealthCheck { async checkHealth(serverUrl: string): Promise<boolean> { try { const response = await fetch( `${serverUrl}/.well-known/openid-configuration`, { timeout: 5000 } ); return response.ok; } catch { return false; } } async selectHealthyServer(servers: ProductionOAuthConfig['authServers']): Promise<string> { // Sort by priority const sortedServers = [...servers].sort((a, b) => a.priority - b.priority); for (const server of sortedServers) { if (await this.checkHealth(server.url)) { return server.url; } } throw new Error('No healthy auth servers available'); }} // Metrics for OAuth operationsclass OAuthMetrics { private metrics: MetricsClient; recordTokenRequest(success: boolean, grantType: string, durationMs: number) { this.metrics.increment('oauth.token_request', { success: String(success), grant_type: grantType, }); this.metrics.histogram('oauth.token_request_duration', durationMs, { grant_type: grantType, }); } recordTokenValidation(success: boolean, error?: string) { this.metrics.increment('oauth.token_validation', { success: String(success), error: error || 'none', }); } recordRefreshToken(success: boolean) { this.metrics.increment('oauth.token_refresh', { success: String(success), }); }} // Audit logging for complianceinterface AuthAuditEvent { timestamp: Date; eventType: 'login' | 'logout' | 'token_issued' | 'token_refresh' | 'token_revoked'; userId?: string; clientId: string; ipAddress: string; userAgent: string; success: boolean; failureReason?: string; mfaUsed?: boolean; mfaMethod?: string; sessionId?: string;} class AuthAuditLogger { constructor(private logDestination: AuditLogDestination) {} async logAuthEvent(event: AuthAuditEvent): Promise<void> { // Ensure immutable, append-only logging await this.logDestination.append({ ...event, timestamp: new Date(), logId: crypto.randomUUID(), }); }}If your authorization server goes down, nothing works. Plan for this: multiple replicas, database replication, geographic distribution, and circuit breakers in clients. Test failover scenarios. Your auth infrastructure should be as resilient as your most critical production service.
Testing OAuth Implementations
Testing OAuth2/OIDC integrations presents unique challenges: external dependencies, time-sensitive tokens, and complex flows. Here's how experienced teams approach it:
- •Unit Tests — Test token validation logic with pre-generated test tokens. Mock JWKS responses. Test expiration, audience, issuer validation.
- •Integration Tests — Use test IdP instances (Keycloak in Docker, Auth0 test tenants). Real OAuth flows against isolated environments.
- •Mock Servers — Tools like WireMock or mock-oauth2-server for controlled OAuth endpoint behavior. Test error cases, timeouts, malformed responses.
- •Contract Tests — Verify your client handles standard OIDC responses correctly. Ensure you meet provider expectations.
- •Security Tests — Test for common vulnerabilities: CSRF without state, open redirect via redirect_uri, token injection.
- •E2E Tests — Full browser-based OAuth flows. Tools like Playwright/Cypress with test accounts.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132
// OAuth Testing Utilitiesimport * as jose from 'jose';import { generateKeyPair } from 'jose'; // Generate test keys for JWT creationasync function createTestKeys() { const { publicKey, privateKey } = await generateKeyPair('RS256'); return { publicKey, privateKey };} // Create test JWT with custom claimsasync function createTestToken( privateKey: jose.KeyLike, claims: Record<string, any>, options?: { expiresIn?: string; kid?: string }): Promise<string> { const jwt = new jose.SignJWT(claims) .setProtectedHeader({ alg: 'RS256', typ: 'JWT', kid: options?.kid || 'test-key-1', }) .setIssuedAt() .setIssuer('https://test-auth.example.com') .setAudience('test-client-id'); if (options?.expiresIn) { jwt.setExpirationTime(options.expiresIn); } return jwt.sign(privateKey);} // Test fixture factoryclass OAuthTestFixtures { private privateKey: jose.KeyLike; private publicKey: jose.KeyLike; async init() { const keys = await createTestKeys(); this.privateKey = keys.privateKey; this.publicKey = keys.publicKey; } async validToken(overrides?: Record<string, any>): Promise<string> { return createTestToken(this.privateKey, { sub: 'test-user-123', email: 'test@example.com', scope: 'openid email profile', ...overrides, }, { expiresIn: '1h' }); } async expiredToken(): Promise<string> { return createTestToken(this.privateKey, { sub: 'test-user-123', exp: Math.floor(Date.now() / 1000) - 3600, // 1 hour ago }); } async wrongAudienceToken(): Promise<string> { const claims = { sub: 'test-user-123', aud: 'wrong-client-id' }; return new jose.SignJWT(claims) .setProtectedHeader({ alg: 'RS256', typ: 'JWT' }) .setIssuedAt() .setExpirationTime('1h') .sign(this.privateKey); } // Mock JWKS endpoint response async mockJWKS(): Promise<object> { const publicJwk = await jose.exportJWK(this.publicKey); return { keys: [{ ...publicJwk, kid: 'test-key-1', use: 'sig', alg: 'RS256', }], }; }} // Example test casesdescribe('TokenValidator', () => { let fixtures: OAuthTestFixtures; let validator: TokenValidator; beforeAll(async () => { fixtures = new OAuthTestFixtures(); await fixtures.init(); // Configure validator with test JWKS validator = new TokenValidator({ issuer: 'https://test-auth.example.com', audience: 'test-client-id', jwks: await fixtures.mockJWKS(), }); }); test('validates correct token', async () => { const token = await fixtures.validToken(); const result = await validator.validate(token); expect(result.valid).toBe(true); expect(result.payload?.sub).toBe('test-user-123'); }); test('rejects expired token', async () => { const token = await fixtures.expiredToken(); const result = await validator.validate(token); expect(result.valid).toBe(false); expect(result.error).toContain('expired'); }); test('rejects wrong audience', async () => { const token = await fixtures.wrongAudienceToken(); const result = await validator.validate(token); expect(result.valid).toBe(false); expect(result.error).toContain('audience'); }); test('rejects tampered token', async () => { const token = await fixtures.validToken(); const tamperedToken = token.slice(0, -5) + 'XXXXX'; // Corrupt signature const result = await validator.validate(tamperedToken); expect(result.valid).toBe(false); });});OAuth security depends on rejecting bad tokens. Test expired tokens, wrong audience, wrong issuer, tampered signatures, missing claims, and malformed JWTs. If your validator doesn't reject these, you have a vulnerability. Positive tests alone aren't enough.
KEEP LEARNING
There’s more to understand.
You’re reading the introduction. Create a free account to read the complete lesson, save notes and keep track of your progress.