Skip to main content
Engineering7 min readMarch 3, 2026

OAuth 2.0 Explained for Developers: The Flows That Matter

A practical OAuth 2.0 guide for developers — authorization code flow, PKCE, client credentials, token handling, and what actually goes wrong in production implementations.

James Ross Jr.

James Ross Jr.

Strategic Systems Architect & Enterprise Software Developer

OAuth 2.0 is one of those specifications that takes an hour to understand conceptually and months to implement correctly. The spec has multiple "flows" for different scenarios, each with its own security requirements and common mistakes. Most developers have used OAuth as a consumer (Log In With Google) but fewer have implemented it as a provider or understand why specific security measures are required.

This guide covers the flows you actually need to know, why they work the way they do, and the implementation details that separate secure from insecure.

What OAuth 2.0 Actually Does

OAuth 2.0 is an authorization framework, not an authentication protocol. The distinction matters: OAuth grants a third-party application access to a user's resources without sharing the user's credentials. Authentication (who are you?) is a separate concern, handled by OpenID Connect (OIDC), which is built on top of OAuth 2.0.

The four parties in an OAuth interaction:

Resource Owner: The user who owns the data.

Client: The application requesting access to the data.

Authorization Server: Issues tokens after authenticating the user and getting consent.

Resource Server: Hosts the user's data. Validates tokens before serving resources.

The Authorization Code Flow

This is the right flow for web applications. Never use the Implicit Flow (it has been deprecated).

The sequence:

  1. User clicks "Log in with Google" in your application
  2. Your application redirects to Google's authorization endpoint with these parameters:
    • response_type=code
    • client_id=your-client-id
    • redirect_uri=https://yourapp.com/callback
    • scope=openid email profile
    • state=random-csrf-value
  3. Google authenticates the user and shows a consent screen
  4. User consents; Google redirects back to your redirect_uri with a code and the state value
  5. Your server exchanges the code for tokens via a server-to-server request (not in the browser)
  6. Google returns access token, refresh token, and ID token
// Step 2: Generate the authorization URL
function getAuthorizationUrl() {
  const state = crypto.randomBytes(16).toString('hex')
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: process.env.GOOGLE_CLIENT_ID!,
    redirect_uri: `${process.env.APP_URL}/auth/callback`,
    scope: 'openid email profile',
    state,
    access_type: 'offline',  // Request refresh token
    prompt: 'consent',
  })

  return {
    url: `https://accounts.google.com/o/oauth2/v2/auth?${params}`,
    state,  // Store this in session to verify in callback
  }
}

// Step 5: Exchange code for tokens
async function exchangeCode(code: string) {
  const response = await fetch('https://oauth2.googleapis.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      code,
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      redirect_uri: `${process.env.APP_URL}/auth/callback`,
      grant_type: 'authorization_code',
    }),
  })

  return response.json()
}

The callback handler:

// OAuth callback handler
app.get('/auth/callback', async (c) => {
  const { code, state, error } = c.req.query()

  // Handle authorization errors
  if (error) {
    return c.redirect(`/login?error=${encodeURIComponent(error)}`)
  }

  // Verify state to prevent CSRF
  const sessionState = await getSessionValue(c, 'oauth_state')
  if (state !== sessionState) {
    throw createError({ statusCode: 400, message: 'Invalid state parameter' })
  }

  // Exchange code for tokens
  const tokens = await exchangeCode(code)

  // Get user info from ID token or userinfo endpoint
  const userInfo = await getUserInfo(tokens.access_token)

  // Find or create user in your database
  const user = await upsertUser({
    email: userInfo.email,
    name: userInfo.name,
    avatarUrl: userInfo.picture,
    googleId: userInfo.sub,
  })

  // Create your application session
  await createSession(c, user.id)
  return c.redirect('/dashboard')
})

PKCE: Required for Public Clients

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. It is required for mobile apps and SPAs (public clients where you cannot keep a client secret truly secret) and recommended for all authorization code flows.

// Generate PKCE parameters before the redirect
function generatePKCE() {
  const verifier = crypto.randomBytes(32).toString('base64url')
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url')

  return { verifier, challenge }
}

// Add to the authorization URL
const { verifier, challenge } = generatePKCE()
// Store verifier in session
const params = new URLSearchParams({
  // ... other params
  code_challenge: challenge,
  code_challenge_method: 'S256',
})

// Include verifier in the token exchange
const tokenResponse = await fetch('...token_endpoint', {
  body: new URLSearchParams({
    // ... other params
    code_verifier: verifier,
  }),
})

If the authorization code is intercepted in transit, the attacker cannot exchange it for tokens without the verifier — which was never transmitted and only exists in the legitimate client's session.

Client Credentials Flow

For machine-to-machine (M2M) API communication where there is no user involved. A backend service authenticates directly with the authorization server using its client ID and secret:

async function getClientToken() {
  const credentials = Buffer.from(
    `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`
  ).toString('base64')

  const response = await fetch('https://auth.server.com/oauth/token', {
    method: 'POST',
    headers: {
      'Authorization': `Basic ${credentials}`,
      'Content-Type': 'application/x-www-form-urlencoded',
    },
    body: new URLSearchParams({
      grant_type: 'client_credentials',
      scope: 'read:data write:data',
    }),
  })

  return response.json()
}

Cache the token and refresh it before expiry:

let cachedToken: { value: string; expiresAt: number } | null = null

async function getServiceToken(): Promise<string> {
  if (cachedToken && cachedToken.expiresAt > Date.now() + 60000) {
    return cachedToken.value
  }

  const { access_token, expires_in } = await getClientToken()
  cachedToken = {
    value: access_token,
    expiresAt: Date.now() + expires_in * 1000,
  }

  return access_token
}

Token Refresh

Access tokens expire. Refresh tokens (when provided) allow getting new access tokens without re-authenticating the user:

async function refreshAccessToken(refreshToken: string) {
  const response = await fetch('https://accounts.google.com/o/oauth2/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: process.env.GOOGLE_CLIENT_ID!,
      client_secret: process.env.GOOGLE_CLIENT_SECRET!,
      refresh_token: refreshToken,
      grant_type: 'refresh_token',
    }),
  })

  if (!response.ok) {
    // Refresh token expired or revoked — user must re-authenticate
    throw new UnauthorizedError('Refresh token invalid')
  }

  return response.json()
}

Store tokens encrypted in your database. Never store them in plaintext:

import { createCipheriv, createDecipheriv, randomBytes } from 'crypto'

function encryptToken(token: string): string {
  const iv = randomBytes(16)
  const cipher = createCipheriv('aes-256-gcm', Buffer.from(process.env.ENCRYPTION_KEY!, 'hex'), iv)
  const encrypted = Buffer.concat([cipher.update(token, 'utf8'), cipher.final()])
  const tag = cipher.getAuthTag()
  return [iv.toString('hex'), tag.toString('hex'), encrypted.toString('hex')].join(':')
}

Common Security Mistakes

Not validating the state parameter. The state parameter prevents CSRF attacks — an attacker cannot trick a user into completing an OAuth flow that hands the attacker the resulting tokens. Always generate a cryptographically random state, store it in the session, and verify it in the callback.

Using the authorization code flow without PKCE for SPAs. Public clients should always use PKCE. The client secret is not secret in a browser.

Storing tokens in localStorage. Tokens in localStorage are accessible to any JavaScript on the page, including injected scripts. Use HTTP-only cookies for refresh tokens.

Not handling token expiry gracefully. Expired tokens are a normal case, not an error. Implement silent token refresh so users do not get logged out unnecessarily.

Trusting the ID token without verifying the signature. Always verify the JWT signature using the authorization server's public keys before trusting the claims. Libraries like jose or openid-client handle this correctly.

Using a mature library like better-auth or @auth/core is the right choice for most applications. They handle these security details correctly and are maintained by people who follow OAuth security advisories. Implement OAuth from scratch only when you have specific requirements that libraries cannot meet.


Implementing OAuth for your application or need a security review of an existing auth implementation? Book a call: calendly.com/jamesrossjr.


Keep Reading