Skip to content
Codeloom
Web

Web Authentication with OAuth 2.0 and OpenID Connect

Implement secure authentication using OAuth 2.0 and OIDC: authorization code flow with PKCE, ID tokens, scopes, token management, and common security pitfalls.

·7 min read · By Codeloom
Intermediate 12 min read

What you'll learn

  • How OAuth 2.0 and OpenID Connect work together for authentication
  • Implementing the authorization code flow with PKCE
  • Understanding ID tokens, access tokens, and refresh tokens
  • Managing token lifecycle: storage, refresh, and revocation
  • Avoiding common OAuth security mistakes

Prerequisites

None — this post is self-contained.

OAuth 2.0 is an authorization framework. OpenID Connect (OIDC) is an authentication layer built on top of it. Together, they let your application verify who a user is (authentication) and what they are allowed to access (authorization) without ever handling their password. This guide covers how to implement both correctly, from the authorization code flow to token management in production.

OAuth 2.0 vs OpenID Connect

OAuth 2.0 was designed to grant third-party applications limited access to a user’s resources. It answers the question “is this app allowed to access this data?” but it does not answer “who is this user?” The access token OAuth 2.0 issues is opaque to the client.

OIDC extends OAuth 2.0 by adding an ID token, a signed JWT that contains user identity claims like name, email, and a unique subject identifier. When your application needs to know who the user is, you need OIDC.

In practice, most “Login with Google/GitHub/Microsoft” flows use both: OIDC for authentication (getting the ID token) and OAuth 2.0 for authorization (getting an access token to call APIs).

The Authorization Code Flow with PKCE

The authorization code flow with PKCE (Proof Key for Code Exchange) is the recommended flow for web applications. It works for both server-rendered apps and single-page applications.

Step 1: Generate the Code Verifier and Challenge

Before redirecting to the authorization server, generate a random code verifier and its SHA-256 hash (the challenge):

// Generate PKCE parameters
function generatePKCE() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  const verifier = base64URLEncode(array);

  return crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
    .then(hash => ({
      verifier,
      challenge: base64URLEncode(new Uint8Array(hash)),
    }));
}

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

Step 2: Redirect to the Authorization Server

Build the authorization URL with the required parameters:

async function startLogin() {
  const { verifier, challenge } = await generatePKCE();

  // Store verifier for step 4
  sessionStorage.setItem('pkce_verifier', verifier);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'your-client-id',
    redirect_uri: 'https://app.example.com/callback',
    scope: 'openid profile email',
    state: crypto.randomUUID(),
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  // Store state for CSRF protection
  sessionStorage.setItem('oauth_state', params.get('state'));

  window.location.href =
    `https://auth.example.com/authorize?${params}`;
}

The state parameter prevents CSRF attacks. The scope includes openid to request an ID token via OIDC.

Step 3: Handle the Callback

The authorization server redirects back to your app with an authorization code:

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

  // Verify state matches
  if (state !== sessionStorage.getItem('oauth_state')) {
    throw new Error('State mismatch - possible CSRF attack');
  }

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

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

  // Exchange code for tokens
  const tokens = await exchangeCode(code, verifier);
  return tokens;
}

Step 4: Exchange the Code for Tokens

Send the authorization code and PKCE verifier to the token endpoint:

async function exchangeCode(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',
      client_id: 'your-client-id',
      code,
      redirect_uri: 'https://app.example.com/callback',
      code_verifier: verifier,
    }),
  });

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

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

Understanding the Tokens

The token endpoint returns up to three tokens:

ID Token: A signed JWT containing user identity claims. Decode it to get user information:

function parseIdToken(idToken) {
  const payload = idToken.split('.')[1];
  const decoded = JSON.parse(atob(payload));

  // Validate required claims
  if (decoded.iss !== 'https://auth.example.com') {
    throw new Error('Invalid issuer');
  }
  if (decoded.aud !== 'your-client-id') {
    throw new Error('Invalid audience');
  }
  if (decoded.exp < Date.now() / 1000) {
    throw new Error('Token expired');
  }

  return {
    userId: decoded.sub,
    email: decoded.email,
    name: decoded.name,
  };
}

Always validate the iss (issuer), aud (audience), and exp (expiration) claims. In production, also verify the JWT signature using the provider’s public keys from their JWKS endpoint.

Access Token: Used to call protected APIs. Treat it as opaque, even if it looks like a JWT. Its contents are meant for the resource server, not your application.

Refresh Token: A long-lived token used to obtain new access tokens without re-authenticating the user. Store it securely.

Token Storage and Lifecycle

Where you store tokens determines your security posture:

StorageXSS RiskCSRF RiskRecommendation
localStorageHighNoneAvoid for sensitive tokens
sessionStorageHighNoneAcceptable for short-lived tokens
HttpOnly cookieNoneMediumBest for server-rendered apps
Memory (variable)LowNoneBest for SPAs with BFF

For single-page applications, the Backend-for-Frontend (BFF) pattern is the most secure approach. The tokens never reach the browser:

// Server-side BFF endpoint
app.post('/api/login/callback', async (req, res) => {
  const { code } = req.body;

  // Exchange code on the server where client_secret is safe
  const tokens = await exchangeCode(code);

  // Store tokens in an encrypted, HttpOnly session cookie
  req.session.accessToken = tokens.access_token;
  req.session.refreshToken = tokens.refresh_token;
  req.session.idToken = tokens.id_token;

  res.json({ user: parseIdToken(tokens.id_token) });
});

// Proxy API calls with the stored access token
app.use('/api/proxy', async (req, res) => {
  const response = await fetch(`https://api.example.com${req.path}`, {
    headers: {
      'Authorization': `Bearer ${req.session.accessToken}`,
    },
  });
  res.status(response.status).json(await response.json());
});

Token Refresh

Access tokens expire. Refresh them transparently before API calls fail:

async function fetchWithAuth(url, options = {}) {
  let response = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      'Authorization': `Bearer ${getAccessToken()}`,
    },
  });

  if (response.status === 401) {
    const refreshed = await refreshAccessToken();
    if (refreshed) {
      response = await fetch(url, {
        ...options,
        headers: {
          ...options.headers,
          'Authorization': `Bearer ${getAccessToken()}`,
        },
      });
    } else {
      // Refresh token expired, redirect to login
      startLogin();
    }
  }

  return response;
}

Common Security Mistakes

Not validating the state parameter: Without state validation, an attacker can craft a malicious authorization URL and complete the flow with their own authorization code, linking their account to the victim’s session.

Storing tokens in localStorage: Any XSS vulnerability gives an attacker full access to the tokens. Use HttpOnly cookies or the BFF pattern instead.

Using the implicit flow: The implicit flow returns tokens in the URL fragment, exposing them in browser history and server logs. Always use the authorization code flow with PKCE.

Not validating the ID token signature: Accepting an ID token without verifying its signature means an attacker can forge identity claims. Fetch the provider’s JWKS and verify the signature server-side.

Requesting excessive scopes: Request only the scopes your application needs. Broad scopes increase the blast radius if a token is compromised.

Key Takeaways

OAuth 2.0 handles authorization while OIDC adds authentication through ID tokens. Use the authorization code flow with PKCE for all web applications. Validate ID token claims including issuer, audience, and expiration. Store tokens server-side with the BFF pattern when possible, or in HttpOnly cookies for server-rendered apps. Refresh access tokens transparently and handle refresh token expiration by redirecting to login. Avoid the implicit flow, localStorage token storage, and excessive scope requests.