Engineering17 min read1,606 words

OAuth 2.1 and OIDC Implementation Guide 2026: Modern Authentication Best Practices

Implement secure authentication with OAuth 2.1 and OpenID Connect. Learn PKCE, token handling, refresh strategies, and common security pitfalls to avoid in modern web and mobile applications.

LT

Lisa Thompson

OAuth 2.1 consolidates the best practices from OAuth 2.0 and its security extensions into a streamlined specification. Combined with OpenID Connect (OIDC) for identity, it provides a robust foundation for modern authentication. This guide covers implementation details, security best practices, and common pitfalls to help you build secure authentication systems in 2026.

OAuth 2.1 Key Changes

OAuth 2.1 is not a new version but a consolidation that makes previously optional security measures mandatory and removes insecure patterns.

  • PKCE Required: Proof Key for Code Exchange is mandatory for all clients
  • Implicit Flow Removed: No longer recommended due to security concerns
  • Password Grant Removed: Direct credential exchange is deprecated
  • Bearer Tokens Restricted: Tokens must be sent in headers, not URLs
  • Refresh Token Rotation: Recommended for all refresh token usage
  • Exact Redirect URI Matching: Wildcard redirects are prohibited

Authorization Code Flow with PKCE

PKCE (Proof Key for Code Exchange) protects against authorization code interception attacks. It's now required for all OAuth clients, including confidential clients.

typescript
// OAuth 2.1 Client Implementation with PKCE
import crypto from 'crypto';

class OAuth2Client {
  private clientId: string;
  private redirectUri: string;
  private authorizationEndpoint: string;
  private tokenEndpoint: string;
  private codeVerifier: string | null = null;

  constructor(config: {
    clientId: string;
    redirectUri: string;
    authorizationEndpoint: string;
    tokenEndpoint: string;
  }) {
    this.clientId = config.clientId;
    this.redirectUri = config.redirectUri;
    this.authorizationEndpoint = config.authorizationEndpoint;
    this.tokenEndpoint = config.tokenEndpoint;
  }

  // Generate cryptographically secure code verifier
  private generateCodeVerifier(): string {
    return crypto.randomBytes(32).toString('base64url');
  }

  // Create code challenge from verifier (S256 method)
  private generateCodeChallenge(verifier: string): string {
    return crypto
      .createHash('sha256')
      .update(verifier)
      .digest('base64url');
  }

  // Generate state for CSRF protection
  private generateState(): string {
    return crypto.randomBytes(16).toString('base64url');
  }

  // Step 1: Build authorization URL
  getAuthorizationUrl(scopes: string[]): { url: string; state: string } {
    this.codeVerifier = this.generateCodeVerifier();
    const codeChallenge = this.generateCodeChallenge(this.codeVerifier);
    const state = this.generateState();

    const params = new URLSearchParams({
      response_type: 'code',
      client_id: this.clientId,
      redirect_uri: this.redirectUri,
      scope: scopes.join(' '),
      state: state,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256',
    });

    return {
      url: `${this.authorizationEndpoint}?${params.toString()}`,
      state: state,
    };
  }

  // Step 2: Exchange authorization code for tokens
  async exchangeCode(code: string): Promise<TokenResponse> {
    if (!this.codeVerifier) {
      throw new Error('Code verifier not found. Start a new authorization flow.');
    }

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

    if (!response.ok) {
      const error = await response.json();
      throw new Error(`Token exchange failed: ${error.error_description || error.error}`);
    }

    this.codeVerifier = null; // Clear after use
    return response.json();
  }

  // Step 3: Refresh access token
  async refreshToken(refreshToken: string): Promise<TokenResponse> {
    const response = await fetch(this.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: this.clientId,
      }),
    });

    if (!response.ok) {
      const error = await response.json();
      throw new Error(`Token refresh failed: ${error.error_description || error.error}`);
    }

    return response.json();
  }
}

interface TokenResponse {
  access_token: string;
  token_type: string;
  expires_in: number;
  refresh_token?: string;
  scope?: string;
  id_token?: string; // If using OIDC
}

OpenID Connect Integration

OIDC adds an identity layer on top of OAuth 2.0. The ID token provides user identity information in a verifiable JWT format.

typescript
// OIDC ID Token Validation
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

class OIDCValidator {
  private jwksUri: string;
  private issuer: string;
  private audience: string;
  private client: jwksClient.JwksClient;

  constructor(config: {
    jwksUri: string;
    issuer: string;
    audience: string;
  }) {
    this.jwksUri = config.jwksUri;
    this.issuer = config.issuer;
    this.audience = config.audience;
    
    this.client = jwksClient({
      jwksUri: this.jwksUri,
      cache: true,
      cacheMaxAge: 600000, // 10 minutes
      rateLimit: true,
    });
  }

  private async getSigningKey(kid: string): Promise<string> {
    const key = await this.client.getSigningKey(kid);
    return key.getPublicKey();
  }

  async validateIdToken(idToken: string, nonce?: string): Promise<IDTokenClaims> {
    // Decode header to get key ID
    const decoded = jwt.decode(idToken, { complete: true });
    if (!decoded || !decoded.header.kid) {
      throw new Error('Invalid token format');
    }

    // Get signing key
    const publicKey = await this.getSigningKey(decoded.header.kid);

    // Verify token
    const claims = jwt.verify(idToken, publicKey, {
      algorithms: ['RS256'],
      issuer: this.issuer,
      audience: this.audience,
    }) as IDTokenClaims;

    // Validate nonce if provided (for replay attack prevention)
    if (nonce && claims.nonce !== nonce) {
      throw new Error('Nonce mismatch');
    }

    // Validate at_hash if access token was issued
    // (omitted for brevity)

    return claims;
  }
}

interface IDTokenClaims {
  iss: string;           // Issuer
  sub: string;           // Subject (user ID)
  aud: string | string[];// Audience
  exp: number;           // Expiration time
  iat: number;           // Issued at
  auth_time?: number;    // Authentication time
  nonce?: string;        // Nonce for replay prevention
  email?: string;        // User email
  email_verified?: boolean;
  name?: string;         // Full name
  picture?: string;      // Profile picture URL
}

// Usage example
const validator = new OIDCValidator({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
  issuer: 'https://auth.example.com',
  audience: 'your-client-id',
});

const claims = await validator.validateIdToken(idToken, storedNonce);
console.log(`Authenticated user: ${claims.email}`);

Secure Token Storage

Where and how you store tokens significantly impacts security. Different platforms have different best practices.

typescript
// Browser Token Storage Patterns

// BAD: localStorage is vulnerable to XSS
localStorage.setItem('access_token', token); // Don't do this!

// BETTER: HttpOnly cookies with proper flags
// Set from server response:
// Set-Cookie: access_token=xxx; HttpOnly; Secure; SameSite=Strict; Path=/

// BEST: BFF (Backend for Frontend) pattern
// Tokens never reach the browser - session managed server-side

// React implementation with BFF pattern
class AuthService {
  // All auth operations go through your backend
  async login(provider: string): Promise<void> {
    // Redirect to backend which initiates OAuth flow
    window.location.href = `/api/auth/login/${provider}`;
  }

  async logout(): Promise<void> {
    await fetch('/api/auth/logout', { method: 'POST' });
  }

  async getUser(): Promise<User | null> {
    try {
      const response = await fetch('/api/auth/me');
      if (response.ok) {
        return response.json();
      }
      return null;
    } catch {
      return null;
    }
  }

  async fetchWithAuth(url: string, options?: RequestInit): Promise<Response> {
    // Backend handles token attachment via cookies
    return fetch(url, {
      ...options,
      credentials: 'include', // Send cookies
    });
  }
}
typescript
// Next.js API Route - BFF Token Handler
// app/api/auth/callback/route.ts
import { cookies } from 'next/headers';
import { NextRequest, NextResponse } from 'next/server';

export async function GET(request: NextRequest) {
  const searchParams = request.nextUrl.searchParams;
  const code = searchParams.get('code');
  const state = searchParams.get('state');
  
  // Validate state
  const storedState = cookies().get('oauth_state')?.value;
  if (!state || state !== storedState) {
    return NextResponse.redirect('/auth/error?reason=invalid_state');
  }

  // Exchange code for tokens (server-side)
  const tokenResponse = await fetch(process.env.TOKEN_ENDPOINT!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code!,
      redirect_uri: process.env.REDIRECT_URI!,
      client_id: process.env.CLIENT_ID!,
      client_secret: process.env.CLIENT_SECRET!,
      code_verifier: cookies().get('code_verifier')?.value!,
    }),
  });

  const tokens = await tokenResponse.json();

  // Store tokens in secure HttpOnly cookies
  const response = NextResponse.redirect('/dashboard');
  
  response.cookies.set('access_token', tokens.access_token, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'lax',
    maxAge: tokens.expires_in,
    path: '/',
  });

  if (tokens.refresh_token) {
    response.cookies.set('refresh_token', tokens.refresh_token, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60, // 30 days
      path: '/api/auth/refresh',
    });
  }

  // Clean up OAuth state cookies
  response.cookies.delete('oauth_state');
  response.cookies.delete('code_verifier');

  return response;
}

Refresh Token Rotation

Refresh token rotation issues a new refresh token with each use, invalidating the previous one. This limits the damage from a compromised refresh token.

typescript
// Refresh Token Rotation Implementation
class TokenManager {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;
  private expiresAt: number = 0;
  private refreshPromise: Promise<void> | null = null;

  async getAccessToken(): Promise<string> {
    // Check if token needs refresh (with 60s buffer)
    if (this.accessToken && Date.now() < this.expiresAt - 60000) {
      return this.accessToken;
    }

    // Prevent concurrent refresh requests
    if (this.refreshPromise) {
      await this.refreshPromise;
      return this.accessToken!;
    }

    if (!this.refreshToken) {
      throw new Error('No refresh token available');
    }

    this.refreshPromise = this.doRefresh();
    
    try {
      await this.refreshPromise;
      return this.accessToken!;
    } finally {
      this.refreshPromise = null;
    }
  }

  private async doRefresh(): Promise<void> {
    try {
      const response = await fetch('/api/auth/refresh', {
        method: 'POST',
        credentials: 'include',
      });

      if (!response.ok) {
        // Refresh failed - user needs to re-authenticate
        this.clearTokens();
        window.location.href = '/login';
        throw new Error('Session expired');
      }

      const data = await response.json();
      this.setTokens(data);
    } catch (error) {
      this.clearTokens();
      throw error;
    }
  }

  setTokens(response: TokenResponse): void {
    this.accessToken = response.access_token;
    this.refreshToken = response.refresh_token || this.refreshToken;
    this.expiresAt = Date.now() + response.expires_in * 1000;
  }

  clearTokens(): void {
    this.accessToken = null;
    this.refreshToken = null;
    this.expiresAt = 0;
  }
}

// Server-side refresh endpoint
// app/api/auth/refresh/route.ts
export async function POST(request: NextRequest) {
  const refreshToken = cookies().get('refresh_token')?.value;

  if (!refreshToken) {
    return NextResponse.json(
      { error: 'No refresh token' },
      { status: 401 }
    );
  }

  const response = await fetch(process.env.TOKEN_ENDPOINT!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: process.env.CLIENT_ID!,
      client_secret: process.env.CLIENT_SECRET!,
    }),
  });

  if (!response.ok) {
    // Refresh token is invalid or expired
    const errorResponse = NextResponse.json(
      { error: 'Refresh failed' },
      { status: 401 }
    );
    errorResponse.cookies.delete('access_token');
    errorResponse.cookies.delete('refresh_token');
    return errorResponse;
  }

  const tokens = await response.json();
  
  const successResponse = NextResponse.json({
    expires_in: tokens.expires_in,
  });

  // Update both tokens (rotation)
  successResponse.cookies.set('access_token', tokens.access_token, {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: tokens.expires_in,
  });

  if (tokens.refresh_token) {
    successResponse.cookies.set('refresh_token', tokens.refresh_token, {
      httpOnly: true,
      secure: true,
      sameSite: 'lax',
      maxAge: 30 * 24 * 60 * 60,
      path: '/api/auth/refresh',
    });
  }

  return successResponse;
}

Common Security Pitfalls

OAuth Security Pitfalls to Avoid

Storing tokens in localStorage: Vulnerable to XSS attacks

Missing state parameter: Enables CSRF attacks

Wildcard redirect URIs: Allows open redirects

Implicit flow for SPAs: Exposes tokens in URL fragments

Long-lived access tokens: Increases attack window

Missing PKCE: Vulnerable to authorization code interception

Improper token validation: Skipping signature or claims checks

Insecure token transmission: Not using HTTPS

Client secrets in frontend: Exposes credentials

Missing refresh token rotation: Extends compromise window

Conclusion

OAuth 2.1 with OIDC provides a robust foundation for modern authentication when implemented correctly. The key principles are: always use PKCE, store tokens securely (preferably server-side), implement refresh token rotation, and validate all tokens properly. Following these practices will help you build secure authentication systems that protect your users.

Need help implementing secure authentication for your application? Contact Jishu Labs for expert security consulting and implementation services.

LT

About Lisa Thompson

Lisa Thompson is a Security Engineer at Jishu Labs specializing in application security and identity management. She has implemented authentication systems for financial services and healthcare organizations.

Related Articles

Ready to Build Your Next Project?

Let's discuss how our expert team can help bring your vision to life.

Top-Rated
Software Development
Company

Ready to Get Started?

Get consistent results. Collaborate in real-time.
Build Intelligent Apps. Work with Jishu Labs.

SCHEDULE MY CALL