Skip to main content

OAuth 2.0 and OpenID Connect Implementation Guide

Ryan Dahlberg
Ryan Dahlberg
September 16, 2025 9 min read
Share:
OAuth 2.0 and OpenID Connect Implementation Guide

Introduction

OAuth 2.0 and OpenID Connect (OIDC) have become the de facto standards for authentication and authorization in modern web applications. While OAuth 2.0 handles authorization, OpenID Connect extends it to provide authentication capabilities. Understanding how to properly implement these protocols is crucial for building secure, scalable enterprise applications.

This guide provides a comprehensive walkthrough of implementing OAuth 2.0 and OpenID Connect, covering everything from basic concepts to production-ready implementations.

Understanding the Fundamentals

OAuth 2.0: Authorization Framework

OAuth 2.0 is an authorization framework that enables applications to obtain limited access to user accounts on an HTTP service. It works by delegating user authentication to the service that hosts the user account and authorizing third-party applications to access that account.

Key Components:

  • Resource Owner: The user who authorizes an application to access their account
  • Client: The application requesting access to the user’s account
  • Authorization Server: The server that authenticates the user and issues access tokens
  • Resource Server: The server hosting the protected resources

OpenID Connect: Authentication Layer

OpenID Connect is an identity layer built on top of OAuth 2.0. It adds authentication capabilities and provides standardized ways to obtain user information.

Additional Components:

  • ID Token: A JWT containing user identity information
  • UserInfo Endpoint: An endpoint that returns claims about the authenticated user
  • Discovery Document: A JSON document describing the OIDC provider’s configuration

Grant Types and When to Use Them

Authorization Code Flow

The most secure flow for web applications with a backend server. The client receives an authorization code that it exchanges for tokens on the backend.

Use Cases:

  • Traditional web applications
  • Server-side rendered applications
  • Applications that can securely store client secrets

Implementation:

// Step 1: Redirect user to authorization endpoint
const authorizationUrl = `${authServer}/authorize?` +
  `response_type=code&` +
  `client_id=${clientId}&` +
  `redirect_uri=${encodeURIComponent(redirectUri)}&` +
  `scope=${encodeURIComponent('openid profile email')}&` +
  `state=${state}&` +
  `nonce=${nonce}`;

window.location.href = authorizationUrl;

// Step 2: Exchange authorization code for tokens (backend)
const tokenResponse = await fetch(`${authServer}/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authorizationCode,
    redirect_uri: redirectUri,
    client_id: clientId,
    client_secret: clientSecret,
  }),
});

const { access_token, id_token, refresh_token } = await tokenResponse.json();

Authorization Code Flow with PKCE

An extension of the authorization code flow that provides additional security for public clients (SPAs, mobile apps) that cannot securely store client secrets.

Use Cases:

  • Single-page applications (React, Vue, Angular)
  • Mobile applications
  • Native desktop applications

Implementation:

import crypto from 'crypto';

// Step 1: Generate PKCE parameters
function generateCodeVerifier() {
  return crypto.randomBytes(32).toString('base64url');
}

function generateCodeChallenge(verifier) {
  return crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');
}

const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);

// Step 2: Authorization request with code challenge
const authorizationUrl = `${authServer}/authorize?` +
  `response_type=code&` +
  `client_id=${clientId}&` +
  `redirect_uri=${encodeURIComponent(redirectUri)}&` +
  `scope=${encodeURIComponent('openid profile email')}&` +
  `state=${state}&` +
  `code_challenge=${codeChallenge}&` +
  `code_challenge_method=S256`;

// Step 3: Token exchange with code verifier
const tokenResponse = await fetch(`${authServer}/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authorizationCode,
    redirect_uri: redirectUri,
    client_id: clientId,
    code_verifier: codeVerifier,
  }),
});

Client Credentials Flow

Used for server-to-server authentication where no user is involved.

Use Cases:

  • Microservice authentication
  • Batch jobs
  • Background services
  • API-to-API communication

Implementation:

const tokenResponse = await fetch(`${authServer}/token`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'client_credentials',
    client_id: clientId,
    client_secret: clientSecret,
    scope: 'api.read api.write',
  }),
});

const { access_token } = await tokenResponse.json();

Implementing OpenID Connect

Discovery and Configuration

Always use the OIDC Discovery endpoint to retrieve provider configuration dynamically.

async function getOIDCConfiguration(issuer) {
  const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
  const response = await fetch(discoveryUrl);
  const config = await response.json();

  return {
    authorizationEndpoint: config.authorization_endpoint,
    tokenEndpoint: config.token_endpoint,
    userinfoEndpoint: config.userinfo_endpoint,
    jwksUri: config.jwks_uri,
    supportedScopes: config.scopes_supported,
    supportedResponseTypes: config.response_types_supported,
    supportedGrantTypes: config.grant_types_supported,
  };
}

ID Token Validation

Proper ID token validation is critical for security. Always validate:

  1. Signature using the provider’s public keys
  2. Issuer claim matches expected issuer
  3. Audience claim contains your client ID
  4. Expiration time has not passed
  5. Nonce matches the value sent in the authorization request
import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';

async function validateIDToken(idToken, nonce, issuer, clientId) {
  // Get signing key from JWKS endpoint
  const client = jwksClient({
    jwksUri: `${issuer}/.well-known/jwks.json`,
    cache: true,
    rateLimit: true,
  });

  const getKey = (header, callback) => {
    client.getSigningKey(header.kid, (err, key) => {
      if (err) return callback(err);
      callback(null, key.getPublicKey());
    });
  };

  // Verify token
  return new Promise((resolve, reject) => {
    jwt.verify(idToken, getKey, {
      algorithms: ['RS256'],
      issuer: issuer,
      audience: clientId,
    }, (err, decoded) => {
      if (err) return reject(err);

      // Validate nonce
      if (decoded.nonce !== nonce) {
        return reject(new Error('Nonce mismatch'));
      }

      // Validate expiration
      const now = Math.floor(Date.now() / 1000);
      if (decoded.exp < now) {
        return reject(new Error('Token expired'));
      }

      resolve(decoded);
    });
  });
}

Retrieving User Information

After successful authentication, use the access token to retrieve user claims from the UserInfo endpoint.

async function getUserInfo(accessToken, userinfoEndpoint) {
  const response = await fetch(userinfoEndpoint, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });

  if (!response.ok) {
    throw new Error('Failed to retrieve user information');
  }

  return await response.json();
}

Token Management

Secure Token Storage

Backend Applications:

  • Store tokens in encrypted session storage
  • Use HTTP-only, secure cookies for session IDs
  • Implement proper session management with expiration

Frontend Applications:

  • Never store tokens in localStorage (XSS vulnerability)
  • Use secure, HTTP-only cookies when possible
  • Consider in-memory storage with automatic refresh

Token Refresh Strategy

Implement automatic token refresh before expiration to maintain uninterrupted user sessions.

class TokenManager {
  constructor(config) {
    this.config = config;
    this.tokens = null;
    this.refreshTimer = null;
  }

  async setTokens(tokens) {
    this.tokens = tokens;
    this.scheduleRefresh();
  }

  scheduleRefresh() {
    if (this.refreshTimer) {
      clearTimeout(this.refreshTimer);
    }

    // Refresh 5 minutes before expiration
    const expiresIn = this.tokens.expires_in * 1000;
    const refreshIn = expiresIn - (5 * 60 * 1000);

    this.refreshTimer = setTimeout(async () => {
      try {
        await this.refreshAccessToken();
      } catch (error) {
        console.error('Token refresh failed:', error);
        // Redirect to login
        window.location.href = '/login';
      }
    }, refreshIn);
  }

  async refreshAccessToken() {
    const response = await fetch(this.config.tokenEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: this.tokens.refresh_token,
        client_id: this.config.clientId,
        client_secret: this.config.clientSecret,
      }),
    });

    if (!response.ok) {
      throw new Error('Token refresh failed');
    }

    const newTokens = await response.json();
    await this.setTokens(newTokens);
    return newTokens;
  }

  getAccessToken() {
    return this.tokens?.access_token;
  }
}

Security Best Practices

State Parameter

Always use the state parameter to prevent CSRF attacks.

function generateState() {
  return crypto.randomBytes(16).toString('hex');
}

// Store state in session before redirect
session.oauthState = generateState();

// Validate state after callback
if (req.query.state !== session.oauthState) {
  throw new Error('State mismatch - possible CSRF attack');
}

Nonce for ID Token

Use nonce to prevent replay attacks with ID tokens.

function generateNonce() {
  return crypto.randomBytes(16).toString('hex');
}

// Include in authorization request
const nonce = generateNonce();
session.nonce = nonce;

// Validate in ID token
if (idToken.nonce !== session.nonce) {
  throw new Error('Nonce mismatch');
}

Scope Management

Request only the scopes you need and validate scopes in the response.

const REQUIRED_SCOPES = ['openid', 'profile', 'email'];
const OPTIONAL_SCOPES = ['phone', 'address'];

function validateScopes(grantedScopes) {
  const granted = new Set(grantedScopes.split(' '));
  const missing = REQUIRED_SCOPES.filter(scope => !granted.has(scope));

  if (missing.length > 0) {
    throw new Error(`Missing required scopes: ${missing.join(', ')}`);
  }
}

Token Validation

Always validate tokens before trusting their contents.

async function validateAccessToken(token) {
  // For JWT access tokens
  try {
    const decoded = await validateJWT(token);
    return decoded;
  } catch (error) {
    // For opaque tokens, use introspection
    return await introspectToken(token);
  }
}

async function introspectToken(token) {
  const response = await fetch(introspectionEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      token: token,
      client_id: clientId,
      client_secret: clientSecret,
    }),
  });

  const result = await response.json();

  if (!result.active) {
    throw new Error('Token is not active');
  }

  return result;
}

Common Implementation Patterns

Backend for Frontend (BFF)

Implement OAuth flow in a backend service to protect client credentials and tokens.

// Express.js BFF example
app.get('/auth/login', (req, res) => {
  const state = generateState();
  const nonce = generateNonce();

  req.session.oauthState = state;
  req.session.nonce = nonce;

  const authUrl = buildAuthorizationUrl({ state, nonce });
  res.redirect(authUrl);
});

app.get('/auth/callback', async (req, res) => {
  try {
    validateState(req.query.state, req.session.oauthState);

    const tokens = await exchangeCodeForTokens(req.query.code);
    const idToken = await validateIDToken(tokens.id_token, req.session.nonce);

    req.session.userId = idToken.sub;
    req.session.accessToken = tokens.access_token;
    req.session.refreshToken = tokens.refresh_token;

    res.redirect('/dashboard');
  } catch (error) {
    res.redirect('/login?error=' + error.message);
  }
});

API Gateway Integration

Centralize authentication at the API gateway level.

// API Gateway middleware
async function authenticationMiddleware(req, res, next) {
  const authHeader = req.headers.authorization;

  if (!authHeader || !authHeader.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing or invalid authorization header' });
  }

  const token = authHeader.substring(7);

  try {
    const tokenData = await validateAccessToken(token);

    // Add user context to request
    req.user = {
      id: tokenData.sub,
      email: tokenData.email,
      scopes: tokenData.scope.split(' '),
    };

    next();
  } catch (error) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}

Testing OAuth and OIDC Flows

Unit Testing

Mock OAuth responses to test your implementation logic.

import { jest } from '@jest/globals';

describe('OAuth Implementation', () => {
  it('should exchange authorization code for tokens', async () => {
    const mockFetch = jest.fn().mockResolvedValue({
      ok: true,
      json: async () => ({
        access_token: 'mock_access_token',
        id_token: 'mock_id_token',
        expires_in: 3600,
      }),
    });

    global.fetch = mockFetch;

    const tokens = await exchangeCodeForTokens('mock_code');

    expect(tokens.access_token).toBe('mock_access_token');
    expect(mockFetch).toHaveBeenCalledWith(
      expect.stringContaining('/token'),
      expect.objectContaining({
        method: 'POST',
      })
    );
  });
});

Integration Testing

Test against a real or mock OAuth provider.

import { setupServer } from 'msw/node';
import { rest } from 'msw';

const server = setupServer(
  rest.post('https://auth.example.com/token', (req, res, ctx) => {
    return res(
      ctx.json({
        access_token: 'test_token',
        token_type: 'Bearer',
        expires_in: 3600,
      })
    );
  })
);

beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

Conclusion

Implementing OAuth 2.0 and OpenID Connect correctly is essential for modern application security. By following the patterns and best practices outlined in this guide, you can build secure, scalable authentication and authorization systems that protect your users and resources.

Key takeaways:

  • Choose the appropriate grant type for your application architecture
  • Always validate tokens and use secure storage mechanisms
  • Implement proper error handling and fallback strategies
  • Use PKCE for public clients to enhance security
  • Follow security best practices including state and nonce validation
  • Leverage discovery endpoints for configuration management

Remember that security is not a one-time implementation but an ongoing process. Stay updated with the latest OAuth and OIDC specifications, regularly review your implementation, and conduct security audits to ensure your authentication system remains robust and secure.

#Authentication #OAuth #OpenID Connect #Security #Identity Management