Security and resilience

OAuth 2.1 and OpenID Connect: Authentication Patterns for Production in 2026

OAuth 2.1 consolidates as the authentication standard with mandatory security improvements, while OpenID Connect standardizes identity in modern architectures.

3/10/20266 min readSecurity
OAuth 2.1 and OpenID Connect: Authentication Patterns for Production in 2026

Executive summary

OAuth 2.1 consolidates as the authentication standard with mandatory security improvements, while OpenID Connect standardizes identity in modern architectures.

Last updated: 3/10/2026

The evolution of modern authentication

Production authentication is no longer about "login with Facebook" or "Google Sign-in". It's about granular authorization, tokens with controlled lifecycle, specific flows for each client type, and compliance with regulations like GDPR and LGPD.

OAuth 2.1, consolidated in 2026, resolves historical ambiguities in OAuth 2.0 and makes security practices mandatory rather than optional. For engineering teams, this means less interpretation and more clarity about what's secure and what's not.

OpenID Connect (OIDC), built on OAuth 2.1, adds the identity layer that OAuth alone doesn't provide. While OAuth authorizes access to resources, OIDC provides user identity through a standardized ID Token.

OAuth 2.1: Mandatory changes impacting production

1. PKCE is mandatory for public clients

OAuth 2.0 allowed PKCE (Proof Key for Code Exchange) as optional for public clients (SPAs, mobile apps). OAuth 2.1 makes PKCE mandatory for all public clients.

Why this matters:

Without PKCE, authentication flows in SPAs and mobile apps are vulnerable to authorization code interception attacks. PKCE prevents this by requiring the client to prove it initiated the request.

typescript// PKCE flow for SPA
class PKCEOAuthClient {
  async initiateAuthorization() {
    // Generate code verifier (cryptographically secure)
    const codeVerifier = this.generateRandomString(128);

    // Create code challenge using SHA-256
    const codeChallenge = await this.sha256(codeVerifier);

    // Store verifier temporarily
    sessionStorage.setItem('pkce_verifier', codeVerifier);

    // Redirect to auth endpoint with code_challenge
    const authUrl = new URL('https://auth.example.com/authorize');
    authUrl.searchParams.set('response_type', 'code');
    authUrl.searchParams.set('client_id', this.clientId);
    authUrl.searchParams.set('redirect_uri', this.redirectUri);
    authUrl.searchParams.set('code_challenge', codeChallenge);
    authUrl.searchParams.set('code_challenge_method', 'S256');
    authUrl.searchParams.set('scope', 'openid profile email');
    authUrl.searchParams.set('state', this.generateState());

    window.location.href = authUrl.toString();
  }

  async exchangeCodeForToken(authorizationCode: string) {
    const codeVerifier = sessionStorage.getItem('pkce_verifier');

    // Exchange code for tokens sending code_verifier
    const response = await fetch('https://auth.example.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: authorizationCode,
        redirect_uri: this.redirectUri,
        client_id: this.clientId,
        code_verifier: codeVerifier // Proof of authorship
      })
    });

    return await response.json();
  }

  private generateRandomString(length: number): string {
    const array = new Uint8Array(length);
    crypto.getRandomValues(array);
    return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
  }

  private async sha256(message: string): Promise<string> {
    const encoder = new TextEncoder();
    const data = encoder.encode(message);
    const hash = await crypto.subtle.digest('SHA-256', data);
    return btoa(String.fromCharCode(...new Uint8Array(hash)))
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=+$/, '');
  }
}

2. Refresh tokens with mandatory rotation

OAuth 2.1 makes refresh token rotation mandatory for public clients. When a new refresh token is issued, the previous one is immediately invalidated.

typescript// Refresh token rotation on backend
class TokenService {
  async refreshAccessToken(refreshToken: string, clientInfo: ClientInfo) {
    // 1. Validate current refresh token
    const tokenData = await this.validateRefreshToken(refreshToken);

    if (!tokenData) {
      throw new InvalidTokenError('Invalid or expired refresh token');
    }

    // 2. Invalidate previous refresh token (mandatory rotation)
    await this.invalidateToken(refreshToken);

    // 3. Generate new access token
    const newAccessToken = await this.generateAccessToken(tokenData.userId);

    // 4. Generate new refresh token
    const newRefreshToken = await this.generateRefreshToken(tokenData.userId, clientInfo);

    // 5. Log rotation for token theft detection
    await this.logTokenRotation(tokenData.userId, refreshToken, newRefreshToken);

    return {
      access_token: newAccessToken,
      refresh_token: newRefreshToken,
      expires_in: 3600
    };
  }

  async validateRefreshToken(token: string) {
    // Verify token in database/Redis
    const tokenData = await this.redis.get(`refresh_token:${token}`);

    if (!tokenData) {
      return null;
    }

    // Check expiration
    const parsed = JSON.parse(tokenData);
    if (Date.now() > parsed.expiresAt) {
      await this.redis.del(`refresh_token:${token}`);
      return null;
    }

    return parsed;
  }
}

3. Credentials in URL prohibited

OAuth 2.1 prohibits passing client_secret in query parameters or URL fragments. Credentials must always be sent in the HTTP request body, encoded as application/x-www-form-urlencoded.

typescript// CORRECT: client_secret in request body
const response = await fetch('https://auth.example.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: clientId,
    client_secret: clientSecret // ✅ Secure: in body
  })
});

// INCORRECT: client_secret in query parameter
const response = await fetch(`https://auth.example.com/token?client_secret=${clientSecret}`); // ❌ Insecure

4. Secure redirects are mandatory

OAuth 2.1 requires HTTPS for all authorization and token endpoints. Additionally, redirect_uri must use secure protocols (HTTPS, custom schemes like myapp:// for mobile).

OpenID Connect: Adding identity to OAuth

OIDC defines three standard tokens:

Token TypePurposeContentValidation
Access TokenAuthorize resource accessAuthorization claims (scopes)Validate signature and expiration
ID TokenIdentify userIdentity claims (sub, name, email)Validate signature, issuer, audience
Refresh TokenObtain new access tokenUnique identifier in databaseValidate in database and apply rotation

ID Token validation

typescriptclass OIDCTokenValidator {
  async validateIDToken(idToken: string, config: OIDCConfig): Promise<TokenPayload> {
    const decoded = await this.verifyJWT(idToken, config.issuer);

    // Mandatory OIDC validations
    this.validateIssuer(decoded, config.issuer);
    this.validateAudience(decoded, config.clientId);
    this.validateExpiration(decoded);
    this.validateIssuedAt(decoded);
    this.validateNonce(decoded, config.nonce);

    return decoded;
  }

  private validateIssuer(token: TokenPayload, expectedIssuer: string) {
    if (token.iss !== expectedIssuer) {
      throw new ValidationError('Invalid issuer in ID Token');
    }
  }

  private validateAudience(token: TokenPayload, expectedAudience: string) {
    if (!token.aud.includes(expectedAudience)) {
      throw new ValidationError('Invalid audience in ID Token');
    }
  }

  private validateExpiration(token: TokenPayload) {
    if (Date.now() >= token.exp * 1000) {
      throw new ValidationError('ID Token expired');
    }
  }

  private validateIssuedAt(token: TokenPayload) {
    const clockSkew = 300; // 5 minutes tolerance
    if (Date.now() < token.iat * 1000 - clockSkew) {
      throw new ValidationError('ID Token issued in future');
    }
  }
}

Authentication flows by client type

1. SPA (Single Page Application)

Recommended flow: Authorization Code Flow with PKCE

typescript// SPA OAuth 2.1 client
class SPAOAuthClient {
  constructor(
    private clientId: string,
    private redirectUri: string,
    private authEndpoint: string,
    private tokenEndpoint: string
  ) {}

  async login(): Promise<void> {
    const codeVerifier = this.generatePKCEVerifier();
    const codeChallenge = await this.generatePKCEChallenge(codeVerifier);
    const state = this.generateState();

    // Store for use in callback
    sessionStorage.setItem('oauth_state', state);
    sessionStorage.setItem('oauth_verifier', codeVerifier);

    const authUrl = new URL(this.authEndpoint);
    authUrl.searchParams.set('response_type', 'code');
    authUrl.searchParams.set('client_id', this.clientId);
    authUrl.searchParams.set('redirect_uri', this.redirectUri);
    authUrl.searchParams.set('code_challenge', codeChallenge);
    authUrl.searchParams.set('code_challenge_method', 'S256');
    authUrl.searchParams.set('scope', 'openid profile email');
    authUrl.searchParams.set('state', state);

    window.location.href = authUrl.toString();
  }

  async handleCallback(code: string, state: string): Promise<Tokens> {
    // Validate state to prevent CSRF
    const storedState = sessionStorage.getItem('oauth_state');
    if (state !== storedState) {
      throw new SecurityError('Invalid state parameter');
    }

    const verifier = sessionStorage.getItem('oauth_verifier');

    const response = await fetch(this.tokenEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: this.redirectUri,
        client_id: this.clientId,
        code_verifier: verifier
      })
    });

    const tokens = await response.json();

    // Store tokens securely
    localStorage.setItem('access_token', tokens.access_token);
    localStorage.setItem('refresh_token', tokens.refresh_token);

    return tokens;
  }
}

2. Mobile Application

Recommended flow: Authorization Code Flow with PKCE using custom scheme

typescript// React Native OAuth 2.1 client
import { Linking } from 'react-native';

class MobileOAuthClient {
  private customScheme = 'myapp://oauth/callback';

  async initiateAuth(): Promise<void> {
    const codeVerifier = this.generateVerifier();
    const codeChallenge = await this.generateChallenge(codeVerifier);
    const state = this.generateState();

    // Store in secure storage
    await SecureStore.setItemAsync('oauth_verifier', codeVerifier);
    await SecureStore.setItemAsync('oauth_state', state);

    const authUrl = `https://auth.example.com/authorize?` + new URLSearchParams({
      response_type: 'code',
      client_id: this.clientId,
      redirect_uri: this.customScheme,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256',
      scope: 'openid profile email',
      state: state
    });

    // Open browser or auth app
    await Linking.openURL(authUrl);
  }

  setupDeepLinkHandler() {
    Linking.addEventListener('url', async ({ url }) => {
      const parsed = new URL(url);
      const code = parsed.searchParams.get('code');
      const state = parsed.searchParams.get('state');

      if (code && state) {
        await this.handleCallback(code, state);
      }
    });
  }
}

3. Backend Service (Machine-to-Machine)

Recommended flow: Client Credentials Flow

typescript// Backend service OAuth 2.1 client
class ServiceAccountClient {
  private accessToken: string | null = null;
  private tokenExpiry: number = 0;

  async getAccessToken(): Promise<string> {
    // Reuse token if still valid
    if (this.accessToken && Date.now() < this.tokenExpiry - 60000) {
      return this.accessToken;
    }

    // Get new token
    const response = await fetch('https://auth.example.com/token', {
      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,
        scope: 'api:read api:write'
      })
    });

    const tokens = await response.json();

    this.accessToken = tokens.access_token;
    this.tokenExpiry = Date.now() + tokens.expires_in * 1000;

    return this.accessToken;
  }

  async makeAuthenticatedRequest(url: string, options: RequestInit = {}) {
    const token = await this.getAccessToken();

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`
      }
    });

    // If token expired (401), try refresh once
    if (response.status === 401) {
      this.accessToken = null; // Force refresh
      const newToken = await this.getAccessToken();

      return fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${newToken}`
        }
      });
    }

    return response;
  }
}

4. Device Authorization Flow (TV, IoT, CLI)

OAuth 2.1 includes Device Authorization Flow for devices with limited interface or no browser.

typescript// Device Authorization Flow for CLI/IoT
class DeviceAuthClient {
  async initiateDeviceAuth(): Promise<DeviceAuthResponse> {
    // 1. Request device code and user code
    const response = await fetch('https://auth.example.com/device/code', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        client_id: this.clientId,
        scope: 'openid profile email'
      })
    });

    const { device_code, user_code, verification_uri, expires_in, interval } = await response.json();

    // 2. Display instructions to user
    console.log(`Visit ${verification_uri}`);
    console.log(`Enter code: ${user_code}`);

    // 3. Poll for authorization
    const tokens = await this.pollForToken(device_code, interval, expires_in);

    return tokens;
  }

  private async pollForToken(
    deviceCode: string,
    interval: number,
    expiresIn: number
  ): Promise<Tokens> {
    const startTime = Date.now();

    while (Date.now() - startTime < expiresIn * 1000) {
      const response = await fetch('https://auth.example.com/token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: new URLSearchParams({
          grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
          client_id: this.clientId,
          device_code: deviceCode
        })
      });

      const data = await response.json();

      if (data.access_token) {
        return data;
      }

      if (data.error === 'authorization_pending') {
        // User hasn't authorized yet, continue polling
        await this.sleep(interval * 1000);
        continue;
      }

      if (data.error === 'authorization_declined') {
        throw new Error('User declined authorization');
      }

      if (data.error === 'expired_token') {
        throw new Error('Device code expired');
      }
    }

    throw new Error('Authorization timeout');
  }

  private sleep(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

Security hardening for production

1. Token binding with Proof-of-Possession

For critical applications, implementing DPoP (Demonstrating Proof-of-Possession) prevents replay attacks:

typescript// DPoP header implementation
class DPoPAuthClient {
  async makeDPoPRequest(url: string, method: string, accessToken: string) {
    const dpopHeader = await this.generateDPoPHeader(url, method);

    return fetch(url, {
      method,
      headers: {
        'Authorization': `Bearer ${accessToken}`,
        'DPoP': dpopHeader
      }
    });
  }

  private async generateDPoPHeader(url: string, method: string): Promise<string> {
    const jwk = await this.getDPoPKey();
    const timestamp = Date.now();

    const payload = {
      htu: url, // HTTP Target URI
      htm: method, // HTTP Method
      iat: timestamp, // Issued At
      jti: this.generateJTI() // Unique Identifier
    };

    const header = {
      typ: 'dpop+jwt',
      jwk: jwk,
      alg: 'RS256'
    };

    return await this.signJWT(header, payload);
  }
}

2. Granular scopes and explicit consent

typescript// Scope management
class ScopeManager {
  private readonly SCOPE_HIERARCHY = {
    // More specific scopes include implicit ones
    'profile:read': ['profile'],
    'profile:write': ['profile:read'],
    'profile:delete': ['profile:read', 'profile:write'],
    'email:read': ['email'],
    'email:send': ['email:read']
  };

  getImplicitScopes(grantedScopes: string[]): string[] {
    const implicit = new Set<string>();

    for (const scope of grantedScopes) {
      const hierarchy = this.SCOPE_HIERARCHY[scope];
      if (hierarchy) {
        hierarchy.forEach(s => implicit.add(s));
      }
    }

    return Array.from(implicit);
  }

  checkRequiredScopes(required: string[], granted: string[]): boolean {
    const grantedSet = new Set([
      ...granted,
      ...this.getImplicitScopes(granted)
    ]);

    return required.every(scope => grantedSet.has(scope));
  }
}

3. Rate limiting on authentication endpoints

typescript// Rate limiting to prevent brute force
class AuthRateLimiter {
  private readonly MAX_ATTEMPTS = 5;
  private readonly WINDOW_MS = 15 * 60 * 1000; // 15 minutes

  async checkRateLimit(identifier: string): Promise<boolean> {
    const key = `auth_attempts:${identifier}`;
    const attempts = await this.redis.incr(key);

    if (attempts === 1) {
      await this.redis.expire(key, this.WINDOW_MS);
    }

    return attempts <= this.MAX_ATTEMPTS;
  }

  async recordFailedAttempt(identifier: string): Promise<void> {
    await this.checkRateLimit(identifier);
  }

  async resetAttempts(identifier: string): Promise<void> {
    await this.redis.del(`auth_attempts:${identifier}`);
  }
}

Monitoring and observability

Key metrics for authentication

typescript// Authentication metrics
interface AuthMetrics {
  // Success/failure rates
  loginSuccessRate: number;
  loginFailureRate: number;
  tokenRefreshRate: number;

  // Average authentication time
  averageAuthTimeMs: number;
  p95AuthTimeMs: number;

  // Token usage
  activeAccessTokens: number;
  activeRefreshTokens: number;
  averageTokenLifetime: number;

  // Anomaly detection
  unusualLoginPatterns: {
    userId: string;
    location: string;
    device: string;
    timestamp: Date;
  }[];

  // Errors by type
  errorsByType: {
    invalidCredentials: number;
    expiredTokens: number;
    invalidGrant: number;
    invalidScope: number;
  };
}

Conclusion

OAuth 2.1 and OpenID Connect in 2026 are no longer technical luxuries—they're mandatory foundations for any system dealing with authentication and authorization in production.

The most important change isn't technical, but cultural: authentication cannot be treated as a secondary feature. It requires deep understanding of flows, rigorous security implementation, and continuous monitoring of anomalies.

PKCE, refresh token rotation, ID Token validation, and choosing the correct flow for each client type aren't details—they're essential safeguards that prevent real vulnerabilities.


Your application needs to implement modern, secure authentication? Talk to Imperialis security specialists to design and implement OAuth 2.1 and OpenID Connect with production best practices.

Sources

Related reading