Skip to content
← Auth & Security · intermediate · 13 min · 04 / 08

OAuth 2.0 & OpenID Connect

Delegated authorization and federated identity — let users log in with Google without giving you their Google password.

OAuth 2.0OIDCauthorization codePKCEfederated identity

Real-World Analogy

A hotel key card system: you present your ID to the front desk (the authorization server), they give you a key card (access token) that opens only your room (scoped access). The hotel restaurant doesn’t need to see your ID — they scan the card. You never hand your ID directly to the restaurant.

What OAuth 2.0 Solves

Before OAuth, the only way to let a third-party app access your data on another service was to give it your username and password. The third-party had full access, forever, with no way to revoke it short of changing your password.

OAuth 2.0 is a delegation protocol: users authorize limited access without sharing credentials. The scope of access is explicit, time-limited, and revocable.

OAuth 2.0 itself only handles authorization (“this app can read your calendar”). OpenID Connect (OIDC) adds authentication on top (“this is who the user is”).

Core Roles

  • Resource Owner: The user who owns the data.
  • Client: The app requesting access (your web app, mobile app).
  • Authorization Server (AS): Issues tokens. Google, GitHub, Auth0, or your own server.
  • Resource Server (RS): The API that serves protected data. Validates tokens.

Authorization Code Flow

The most secure flow for web apps and mobile apps. Never exposes tokens in URLs.

1. Client → User: "Please authorize at AS"
2. User → AS: Logs in, grants permission
3. AS → Client: Authorization code (short-lived, single-use)
4. Client → AS: Exchange code + client_secret for tokens
5. AS → Client: Access token + refresh token + ID token
6. Client → RS: Access token in Authorization header

Step 1-2: Redirect user to authorization server

import crypto from 'crypto';

function buildAuthorizationUrl(state: string): string {
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.OAUTH_CLIENT_ID!,
    redirect_uri: 'https://myapp.com/auth/callback',
    scope: 'openid profile email',
    state,  // CSRF protection — random value, verified on callback
  });

  return `https://auth.google.com/o/oauth2/v2/auth?${params}`;
}

app.get('/auth/login', (req, res) => {
  const state = crypto.randomBytes(16).toString('hex');
  req.session.oauthState = state; // store for verification
  res.redirect(buildAuthorizationUrl(state));
});

Step 3-4: Handle callback and exchange code

app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query;

  // Verify state to prevent CSRF
  if (state !== req.session.oauthState) {
    return res.status(400).json({ error: 'Invalid state' });
  }

  // Exchange code for tokens
  const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code as string,
      redirect_uri: 'https://myapp.com/auth/callback',
      client_id: process.env.OAUTH_CLIENT_ID!,
      client_secret: process.env.OAUTH_CLIENT_SECRET!,
    }),
  });

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

  // Verify and decode the ID token (OIDC)
  const identity = await verifyIdToken(id_token);

  // Create or update user in your database
  const user = await upsertUser({
    externalId: identity.sub,
    provider: 'google',
    email: identity.email,
    name: identity.name,
  });

  req.session.userId = user.id;
  res.redirect('/dashboard');
});

PKCE: Authorization Code Flow for Public Clients

Mobile apps and SPAs can’t keep a client_secret secret (it’s in the bundle). PKCE (Proof Key for Code Exchange) replaces the secret with a cryptographic challenge:

function generatePKCE(): { verifier: string; challenge: string } {
  // code_verifier: random 43-128 char string
  const verifier = crypto.randomBytes(32).toString('base64url');

  // code_challenge: SHA-256 of verifier, base64url encoded
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  return { verifier, challenge };
}

// Step 1: include challenge in authorization URL
const { verifier, challenge } = generatePKCE();
sessionStorage.setItem('pkce_verifier', verifier); // store for callback

const params = new URLSearchParams({
  // ...other params...
  code_challenge: challenge,
  code_challenge_method: 'S256',
});

// Step 2: include verifier in token exchange (instead of client_secret)
body: new URLSearchParams({
  grant_type: 'authorization_code',
  code,
  redirect_uri: '...',
  client_id: '...',
  code_verifier: sessionStorage.getItem('pkce_verifier')!, // no secret needed
})

The AS verifies that SHA-256(verifier) === challenge. An attacker who intercepts the authorization code can’t exchange it without the verifier.

OpenID Connect: Identity on Top of OAuth

OIDC adds an ID token — a JWT containing user identity claims. It’s what turns OAuth (authorization) into a login system (authentication).

import { createRemoteJWKSet, jwtVerify } from 'jose';

const GOOGLE_JWKS = createRemoteJWKSet(
  new URL('https://www.googleapis.com/oauth2/v3/certs')
);

async function verifyIdToken(idToken: string) {
  const { payload } = await jwtVerify(idToken, GOOGLE_JWKS, {
    issuer: 'https://accounts.google.com',
    audience: process.env.OAUTH_CLIENT_ID!, // MUST match your client_id
  });

  return {
    sub: payload.sub as string,          // stable user ID — use this as primary key
    email: payload.email as string,
    name: payload.name as string,
    emailVerified: payload.email_verified as boolean,
  };
}

Use sub (subject) as your primary key for external users, not email. Email can change. sub is a stable, provider-specific user identifier.

Discovery Document

OIDC providers publish a discovery document at /.well-known/openid-configuration. It tells you where to find the authorization endpoint, token endpoint, JWKS, and supported scopes — so you don’t hardcode URLs:

async function getProviderConfig(issuer: string) {
  const response = await fetch(`${issuer}/.well-known/openid-configuration`);
  return response.json();
  // Returns: { authorization_endpoint, token_endpoint, jwks_uri, ... }
}

// Cache this — it doesn't change often
const googleConfig = await getProviderConfig('https://accounts.google.com');

Token Types

TokenLifetimePurpose
Authorization code~10 minutesOne-time exchange for tokens
Access token1–60 minutesCall resource server APIs
Refresh tokenDays–monthsGet new access tokens
ID tokenSame as accessVerify user identity

Access tokens should be short-lived. Refresh tokens should be stored securely (httpOnly cookie), rotated on use, and bound to the session.

Building Your Own Authorization Server

For internal APIs or B2B, you might run your own AS. Libraries like node-oidc-provider handle the protocol complexity:

import Provider from 'oidc-provider';

const oidc = new Provider('https://auth.yourapp.com', {
  clients: [{
    client_id: 'web-app',
    client_secret: process.env.WEB_APP_SECRET,
    redirect_uris: ['https://yourapp.com/auth/callback'],
    grant_types: ['authorization_code', 'refresh_token'],
    scope: 'openid profile email',
  }],

  async findAccount(ctx, id) {
    const user = await db.users.findById(id);
    return {
      accountId: id,
      async claims() {
        return { sub: id, email: user.email, name: user.name };
      },
    };
  },
});

For most teams, use a managed AS (Auth0, Clerk, Supabase Auth, AWS Cognito) and focus on your product.

Common OAuth Mistakes

Open redirects in redirect_uri:

// WRONG — allows redirecting to attacker.com after auth
redirect_uri: req.query.redirect_uri

// RIGHT — validate against registered URIs
const allowedRedirects = ['https://myapp.com/auth/callback'];
if (!allowedRedirects.includes(req.query.redirect_uri)) {
  return res.status(400).json({ error: 'Invalid redirect_uri' });
}

Skipping state parameter (CSRF): An attacker can initiate an OAuth flow and trick a victim into completing it, binding the victim’s session to the attacker’s authorization code. Always use state.

Using access tokens as identity: Access tokens prove authorization, not identity. Use the ID token (or userinfo endpoint) to get the user’s identity. Don’t decode an opaque access token and trust its contents.

Storing tokens in localStorage: Subject to XSS. Use httpOnly cookies for refresh tokens, memory for access tokens.