Home/Blog/Development/OAuth 2.0 & OpenID Connect Implementation Guide
Development

OAuth 2.0 & OpenID Connect Implementation Guide

Complete guide to implementing OAuth 2.0 and OpenID Connect (OIDC) for API authentication. Covers Authorization Code with PKCE, Client Credentials, token management, and security best practices aligned with OAuth 2.1.

OAuth 2.0 & OpenID Connect Implementation Guide

OAuth 2.0 and OpenID Connect (OIDC) are the industry standards for API authentication and authorization. This guide covers everything you need to implement secure OAuth/OIDC flows, aligned with the latest OAuth 2.1 security best practices.

OAuth 2.0 vs OpenID Connect

┌─────────────────────────────────────────────────────────────────┐
│                    OAUTH 2.0 vs OIDC                            │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  OAuth 2.0 = AUTHORIZATION                                      │
│  "What can this app access?"                                    │
│  └─► Access Tokens → API Access                                 │
│                                                                 │
│  OpenID Connect = AUTHENTICATION + Authorization                │
│  "Who is this user?" + "What can they access?"                  │
│  └─► ID Tokens → User Identity                                  │
│  └─► Access Tokens → API Access                                 │
│                                                                 │
│  OIDC = OAuth 2.0 + Identity Layer                              │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

Grant Type Selection Guide

What type of client are you building?
│
├─► User-facing app (web, mobile, SPA)
│   │
│   └─► Use: Authorization Code + PKCE
│       • User authenticates at IdP
│       • Code exchanged for tokens
│       • Works for all client types
│
├─► Server-to-server (no user)
│   │
│   └─► Use: Client Credentials
│       • Machine-to-machine auth
│       • Client authenticates directly
│       • No user involvement
│
├─► Device with no keyboard (TV, IoT)
│   │
│   └─► Use: Device Authorization Grant
│       • User authenticates on phone/computer
│       • Device polls for tokens
│       • Good for CLI tools too
│
└─► ❌ DEPRECATED (Don't use)
    • Implicit Grant - tokens exposed in URL
    • ROPC - user credentials shared with app

Authorization Code Flow with PKCE

This is the recommended flow for all user-facing applications.

Flow Diagram

┌─────────┐                                    ┌─────────┐
│  User   │                                    │   IdP   │
└────┬────┘                                    └────┬────┘
     │                                              │
     │  1. Click "Login"                            │
     ▼                                              │
┌─────────┐  2. Redirect with code_challenge  ┌────┴────┐
│  App    │ ─────────────────────────────────►│  Auth   │
│ (Client)│                                   │Endpoint │
└────┬────┘                                   └────┬────┘
     │                                              │
     │         3. User authenticates                │
     │         4. User consents to scopes           │
     │                                              │
     │  5. Redirect with authorization code         │
     │◄─────────────────────────────────────────────│
     │                                              │
     │  6. Exchange code + code_verifier       ┌────┴────┐
     │ ─────────────────────────────────────────►│ Token  │
     │                                         │Endpoint │
     │  7. Receive tokens                      └────┬────┘
     │◄─────────────────────────────────────────────│
     │                                              │
     │  8. Use access_token for API calls           │
     ▼                                              ▼
┌─────────┐                                    ┌─────────┐
│   API   │◄───────────────────────────────────│ Tokens  │
└─────────┘                                    └─────────┘

Step 1: Generate PKCE Challenge

// Generate cryptographically random code verifier
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64URLEncode(array);
}

// Create code challenge (SHA-256 hash of verifier)
async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64URLEncode(new Uint8Array(hash));
}

// Base64URL encoding (no padding, URL-safe)
function base64URLEncode(buffer) {
  return btoa(String.fromCharCode(...buffer))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

// Generate both values
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);

// Store codeVerifier securely (session storage for SPAs, server session for web apps)
sessionStorage.setItem('pkce_verifier', codeVerifier);

Step 2: Build Authorization Request

function buildAuthorizationURL() {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'your_client_id',
    redirect_uri: 'https://yourapp.com/callback',
    scope: 'openid profile email read:data',
    state: generateRandomString(32),  // CSRF protection
    nonce: generateRandomString(32),  // ID token replay protection
    code_challenge: codeChallenge,
    code_challenge_method: 'S256'
  });

  // Store state for validation
  sessionStorage.setItem('oauth_state', params.get('state'));
  sessionStorage.setItem('oauth_nonce', params.get('nonce'));

  return `https://auth.example.com/authorize?${params}`;
}

// Redirect user to authorization endpoint
window.location.href = buildAuthorizationURL();

Step 3: Handle Callback and Exchange Code

async function handleCallback() {
  const params = new URLSearchParams(window.location.search);

  // Validate state to prevent CSRF
  const storedState = sessionStorage.getItem('oauth_state');
  if (params.get('state') !== storedState) {
    throw new Error('State mismatch - possible CSRF attack');
  }

  // Check for errors
  if (params.has('error')) {
    throw new Error(`OAuth error: ${params.get('error_description')}`);
  }

  // Get authorization code
  const code = params.get('code');
  const codeVerifier = sessionStorage.getItem('pkce_verifier');

  // Exchange code for tokens
  const tokens = await exchangeCode(code, codeVerifier);

  // Clean up
  sessionStorage.removeItem('oauth_state');
  sessionStorage.removeItem('oauth_nonce');
  sessionStorage.removeItem('pkce_verifier');

  return tokens;
}

async function exchangeCode(code, codeVerifier) {
  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',
      client_id: 'your_client_id',
      code: code,
      redirect_uri: 'https://yourapp.com/callback',
      code_verifier: codeVerifier
    })
  });

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

  return response.json();
  // Returns: { access_token, refresh_token, id_token, expires_in, token_type }
}

Step 4: Validate ID Token (OIDC)

async function validateIdToken(idToken, nonce) {
  // Decode token (without verification for inspection)
  const parts = idToken.split('.');
  const header = JSON.parse(atob(parts[0]));
  const payload = JSON.parse(atob(parts[1]));

  // Get signing keys from IdP
  const jwksResponse = await fetch('https://auth.example.com/.well-known/jwks.json');
  const jwks = await jwksResponse.json();

  // Find the key matching the token's kid
  const key = jwks.keys.find(k => k.kid === header.kid);
  if (!key) {
    throw new Error('Signing key not found');
  }

  // Verify signature (use a library like jose)
  const isValid = await verifySignature(idToken, key);
  if (!isValid) {
    throw new Error('Invalid signature');
  }

  // Validate claims
  const now = Math.floor(Date.now() / 1000);

  if (payload.iss !== 'https://auth.example.com') {
    throw new Error('Invalid issuer');
  }

  if (payload.aud !== 'your_client_id') {
    throw new Error('Invalid audience');
  }

  if (payload.exp < now) {
    throw new Error('Token expired');
  }

  if (payload.iat > now + 60) {  // Allow 60s clock skew
    throw new Error('Token issued in the future');
  }

  if (payload.nonce !== nonce) {
    throw new Error('Invalid nonce - possible replay attack');
  }

  return payload;  // User identity information
}

Client Credentials Flow

For server-to-server authentication without user involvement.

async function getClientCredentialsToken() {
  const response = await fetch('https://auth.example.com/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      // Client authentication via Basic auth or in body
      'Authorization': `Basic ${btoa(`${clientId}:${clientSecret}`)}`
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      scope: 'read:data write:data'
    })
  });

  const { access_token, expires_in } = await response.json();

  // Cache token until near expiration
  return access_token;
}

// Example: Service-to-service API call
async function callInternalAPI() {
  const token = await getClientCredentialsToken();

  const response = await fetch('https://api.internal.com/data', {
    headers: {
      'Authorization': `Bearer ${token}`
    }
  });

  return response.json();
}

Token Management Best Practices

Secure Token Storage

PlatformAccess TokenRefresh Token
Web (SSR)HttpOnly cookieHttpOnly cookie (separate)
SPAMemory onlyHttpOnly cookie via BFF
MobileSecure storageKeychain/Keystore
ServerEncrypted DB/secrets managerSame, with higher protection

Token Refresh with Rotation

async function refreshAccessToken(refreshToken) {
  const response = await fetch('https://auth.example.com/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      client_id: 'your_client_id',
      refresh_token: refreshToken
    })
  });

  if (!response.ok) {
    if (response.status === 400) {
      // Refresh token invalid/expired - require re-authentication
      throw new Error('Session expired - please login again');
    }
    throw new Error('Token refresh failed');
  }

  const tokens = await response.json();

  // IMPORTANT: Save the NEW refresh token (rotation)
  // Old refresh token is now invalid
  await saveTokens(tokens.access_token, tokens.refresh_token);

  return tokens.access_token;
}

Automatic Token Refresh

class TokenManager {
  constructor() {
    this.accessToken = null;
    this.refreshToken = null;
    this.expiresAt = null;
  }

  async getAccessToken() {
    // Check if token is expired or about to expire (60s buffer)
    if (!this.accessToken || Date.now() >= this.expiresAt - 60000) {
      await this.refresh();
    }
    return this.accessToken;
  }

  async refresh() {
    if (!this.refreshToken) {
      throw new Error('No refresh token - authentication required');
    }

    try {
      const tokens = await refreshAccessToken(this.refreshToken);
      this.setTokens(tokens);
    } catch (error) {
      // Clear tokens and redirect to login
      this.clear();
      throw error;
    }
  }

  setTokens({ access_token, refresh_token, expires_in }) {
    this.accessToken = access_token;
    this.refreshToken = refresh_token;
    this.expiresAt = Date.now() + (expires_in * 1000);
  }

  clear() {
    this.accessToken = null;
    this.refreshToken = null;
    this.expiresAt = null;
  }
}

// Usage with fetch wrapper
const tokenManager = new TokenManager();

async function authenticatedFetch(url, options = {}) {
  const token = await tokenManager.getAccessToken();

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

Security Checklist

Authorization Request

  • Use PKCE (code_challenge + code_challenge_method=S256)
  • Include state parameter (random, >= 32 bytes)
  • Include nonce parameter for OIDC (random, >= 32 bytes)
  • Use exact redirect_uri matching
  • Request minimum necessary scopes

Token Exchange

  • Validate state parameter matches stored value
  • Use code_verifier with PKCE
  • Exchange code promptly (codes expire quickly)
  • Use HTTPS for all token endpoint calls

Token Validation

  • Verify signature using JWKS
  • Validate iss (issuer) claim
  • Validate aud (audience) claim
  • Check exp (expiration) claim
  • Validate nonce matches for ID tokens
  • Use clock skew tolerance (e.g., 60 seconds)

Token Storage

  • Never store tokens in localStorage (XSS vulnerable)
  • Use HttpOnly cookies for web apps
  • Use platform secure storage for mobile
  • Encrypt tokens at rest on servers

Token Lifecycle

  • Implement refresh token rotation
  • Set reasonable expiration times
  • Support token revocation
  • Clear tokens on logout

Common Pitfalls

MistakeRiskSolution
Skipping PKCECode interceptionAlways use PKCE, even for confidential clients
Tokens in localStorageXSS theftUse HttpOnly cookies or memory
Long-lived access tokensExtended exposureShort expiry (15 min) + refresh tokens
No state parameterCSRF attacksRandom state, validate on callback
Trusting token claimsForged tokensAlways verify signatures
Refresh token reuseUndetected theftImplement rotation

Next Steps

Frequently Asked Questions

Find answers to common questions

OAuth 2.0 is an authorization framework—it grants applications access to resources on behalf of users without sharing passwords. OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0—it adds authentication, providing information about who the user is via ID tokens. Use OAuth alone when you only need resource access (API calls). Use OIDC when you need to know user identity (login, profile information). Most modern implementations use both together.

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. The client generates a random code_verifier and sends its SHA256 hash (code_challenge) with the authorization request. When exchanging the code for tokens, the client sends the original verifier, which the server validates against the stored challenge. OAuth 2.1 mandates PKCE for ALL clients (including confidential server-side apps) because it provides defense-in-depth against code leakage with minimal implementation cost.

Use Authorization Code + PKCE for web apps, mobile apps, and SPAs—any scenario where a user is present. Use Client Credentials for server-to-server (machine-to-machine) communication with no user involvement. Use Device Authorization Grant for IoT devices, smart TVs, and CLIs without keyboards. Never use Implicit Grant or Resource Owner Password Credentials—they're deprecated in OAuth 2.1 due to security vulnerabilities.

For web apps: Store tokens in HttpOnly, Secure, SameSite=Strict cookies—never in localStorage or sessionStorage (XSS vulnerable). For mobile apps: Use platform secure storage (iOS Keychain, Android Keystore). For SPAs: Keep tokens in memory only; use silent refresh or refresh token rotation. For servers: Store in encrypted databases or secrets managers. Never log tokens. Implement token binding where supported.

Access tokens authorize API requests—they're short-lived (minutes to hours) and sent with every API call. Refresh tokens obtain new access tokens—they're long-lived (days to months) and only sent to the authorization server. This separation limits exposure: if an access token leaks, damage is time-limited. Refresh tokens should be rotated on each use (one-time use) and stored more securely than access tokens.

Implement refresh token rotation—issue a new refresh token with each access token refresh, invalidating the old one. This detects token theft (attacker and legitimate user both try to refresh, second attempt fails). Set reasonable expiration (7-30 days). Implement absolute expiration requiring re-authentication after extended periods. Bind refresh tokens to the client (confidential clients use client authentication). Store server-side to enable revocation.

Request minimum necessary scopes (principle of least privilege). For OIDC identity, use openid (required), profile (name, picture), email (email address). For API access, define granular scopes like read:users, write:orders. Avoid wildcard scopes. Let users see and consent to each scope. Review and re-request scopes when needed rather than requesting everything upfront. Document scope meanings clearly for users.

Validate:

  1. Signature using the provider's JWKS (JSON Web Key Set)
  2. iss (issuer) matches expected IdP
  3. aud (audience) includes your client_id
  4. exp (expiration) is in the future
  5. iat (issued at) is reasonable
  6. nonce matches what you sent (prevents replay)
  7. azp (authorized party) if present matches your client_id.

Libraries handle most validation—use them rather than implementing manually.

Implicit flow returns tokens directly in the URL fragment after authorization—simple but insecure because tokens are exposed in browser history, logs, and referrer headers. Authorization Code flow returns a short-lived code, exchanged server-side for tokens—more secure as tokens never touch the browser. OAuth 2.1 removes Implicit flow entirely. Always use Authorization Code + PKCE, even for SPAs (exchange code in browser using CORS).

OIDC defines several logout mechanisms:

  1. RP-Initiated Logout—redirect user to IdP's end_session_endpoint with id_token_hint
  2. Front-Channel Logout—IdP loads logout URLs in iframes for all RPs
  3. Back-Channel Logout—IdP POSTs logout tokens to RPs.

At minimum, implement local logout (clear local tokens/session) plus RP-initiated logout. Consider token revocation for immediate invalidation. Handle logout failures gracefully—user should still be logged out locally.

Building Something Great?

Our development team builds secure, scalable applications. From APIs to full platforms, we turn your ideas into production-ready software.