Security

Understanding OAuth 2.0 Flow

Mayur Dabhi
Mayur Dabhi
April 30, 2026
14 min read

Every time you click "Sign in with Google" or authorize a third-party app to access your GitHub repos, OAuth 2.0 is working behind the scenes. Yet despite its ubiquity, OAuth 2.0 remains one of the most misunderstood protocols in modern web development. Developers often confuse it with authentication, implement the wrong grant type, or leave security holes that attackers are eager to exploit. This guide cuts through the confusion — you'll understand what OAuth 2.0 actually is, how each grant type works step-by-step, and how to implement it securely in real applications.

OAuth 2.0 vs Authentication

OAuth 2.0 is an authorization framework, not an authentication protocol. It grants access to resources on behalf of a user — it does not prove who that user is. For authentication (login), you need OpenID Connect (OIDC), which builds on top of OAuth 2.0.

What Is OAuth 2.0?

OAuth 2.0 (Open Authorization) is an industry-standard framework that allows a third-party application to obtain limited access to a service on behalf of a user, without exposing the user's credentials. It was designed to replace OAuth 1.0 with a simpler, more secure approach.

The core problem OAuth 2.0 solves: delegated access. Before OAuth, if you wanted a photo-printing app to access your Google Photos, you'd have to give the app your Google password — which is terrible security. OAuth 2.0 lets Google issue a limited-scope token to the printing app without ever sharing your password.

The Four Key Roles

Resource Owner (The User) Client (Your App) Authorization Server (Issues tokens) Resource Server (Protected API) 1. Authorizes 2. Authenticates 3. Auth Code 4. Access Token 5. API Request OAuth 2.0 — Four Core Roles

The four OAuth 2.0 roles and how they interact

OAuth 2.0 Grant Types

OAuth 2.0 defines several "grant types" — each is a different flow for obtaining an access token, suited to different client types and use cases.

Grant Type Best For Status
Authorization Code + PKCE Web apps, SPAs, mobile apps ✅ Recommended
Client Credentials Machine-to-machine (no user) ✅ Recommended
Implicit SPAs (legacy) ⛔ Deprecated
Resource Owner Password First-party apps only ⛔ Deprecated
Device Authorization Smart TVs, CLI tools ✅ Supported
Avoid Deprecated Grants

The Implicit and Resource Owner Password Credentials grants are deprecated in OAuth 2.1. The Implicit flow exposes tokens in URLs where they can be stolen from browser history or referrer headers. Always use Authorization Code + PKCE instead.

Authorization Code Flow (with PKCE)

The Authorization Code flow is the gold standard for OAuth 2.0. It's used when your application can securely store a client secret — or in the case of SPAs and mobile apps, it's enhanced with PKCE (Proof Key for Code Exchange) to protect against authorization code interception attacks.

Step-by-Step: Authorization Code + PKCE

1

Generate PKCE Verifier and Challenge

The client generates a random code_verifier string and derives a code_challenge from it using SHA-256. This pair proves the auth request and token exchange come from the same client.

2

Redirect User to Authorization Server

The client redirects the user's browser to the authorization endpoint with the code_challenge, requested scopes, and a redirect_uri.

3

User Authenticates and Consents

The authorization server shows the user a login page and consent screen. After approval, it redirects back to the client with a short-lived authorization_code.

4

Exchange Code for Tokens

The client sends the authorization_code along with the original code_verifier directly to the token endpoint (server-to-server). The authorization server verifies the pair and returns access and refresh tokens.

5

Access Protected Resources

The client includes the access_token in API requests as a Bearer token in the Authorization header.

User Browser Auth Server Resource Server 1. Authorization Request + code_challenge 2. Show Login / Consent Page 3. User Authenticates & Grants Permission 4. Redirect with authorization_code 5. POST /token { code, code_verifier, client_id } 6. access_token + refresh_token 7. GET /api/resource — Authorization: Bearer <token> 8. Protected Resource Response

Authorization Code + PKCE flow — the recommended approach for all app types

Implementing PKCE in JavaScript

pkce.js — Generate Verifier and Challenge
// Generate a cryptographically random code_verifier
function generateCodeVerifier() {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// Derive code_challenge from verifier using SHA-256
async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return btoa(String.fromCharCode(...new Uint8Array(digest)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');
}

// Build authorization URL and redirect
async function startOAuthFlow() {
  const verifier = generateCodeVerifier();
  const challenge = await generateCodeChallenge(verifier);

  // Store verifier in sessionStorage — needed for token exchange
  sessionStorage.setItem('pkce_verifier', verifier);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: 'YOUR_CLIENT_ID',
    redirect_uri: 'https://yourapp.com/callback',
    scope: 'read:profile read:email',
    state: crypto.randomUUID(), // CSRF protection
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  window.location.href = `https://auth.example.com/authorize?${params}`;
}
callback.js — Exchange Authorization Code for Tokens
// On the redirect callback page
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');
  const verifier = sessionStorage.getItem('pkce_verifier');

  if (!code || !verifier) {
    throw new Error('Missing code or 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',
      code,
      redirect_uri: 'https://yourapp.com/callback',
      client_id: 'YOUR_CLIENT_ID',
      code_verifier: verifier,
    }),
  });

  const tokens = await response.json();
  // { access_token, refresh_token, token_type, expires_in }

  // Store tokens securely — httpOnly cookies are preferred
  // Never store access tokens in localStorage
  sessionStorage.removeItem('pkce_verifier');
  return tokens;
}

Client Credentials Flow

When there is no user involved — a background service calling another service, a cron job fetching data from an API, a microservice authenticating to another microservice — the Client Credentials grant is the right choice.

It's the simplest OAuth 2.0 flow: the client authenticates directly with the authorization server using its own credentials (client ID + secret) and receives an access token. No user redirect, no consent screen.

server.js — Client Credentials Flow (Node.js)
const axios = require('axios');

class OAuthClient {
  constructor(clientId, clientSecret, tokenUrl) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.tokenUrl = tokenUrl;
    this.token = null;
    this.tokenExpiry = null;
  }

  async getAccessToken() {
    // Return cached token if still valid (with 60s buffer)
    if (this.token && Date.now() < this.tokenExpiry - 60000) {
      return this.token;
    }

    const response = await axios.post(
      this.tokenUrl,
      new URLSearchParams({
        grant_type: 'client_credentials',
        scope: 'api:read api:write',
      }),
      {
        auth: {
          username: this.clientId,
          password: this.clientSecret,
        },
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      }
    );

    this.token = response.data.access_token;
    this.tokenExpiry = Date.now() + response.data.expires_in * 1000;
    return this.token;
  }

  async callAPI(endpoint) {
    const token = await this.getAccessToken();
    return axios.get(endpoint, {
      headers: { Authorization: `Bearer ${token}` },
    });
  }
}

// Usage
const client = new OAuthClient(
  process.env.CLIENT_ID,
  process.env.CLIENT_SECRET,
  'https://auth.example.com/token'
);

const data = await client.callAPI('https://api.example.com/reports');
Token Caching

Always cache access tokens until they expire. Requesting a new token on every API call wastes time and may hit rate limits on the authorization server. The example above caches the token and only refreshes it 60 seconds before expiry.

Understanding OAuth 2.0 Tokens

OAuth 2.0 uses three main types of tokens, each with a different purpose and lifetime.

Short-lived credential used to access protected resources. Typically expires in 15 minutes to 1 hour.

Usually a JWT (JSON Web Token) that the resource server can validate without calling the authorization server:

// Decoded access token (JWT)
{
  "iss": "https://auth.example.com",
  "sub": "user_12345",
  "aud": "https://api.example.com",
  "exp": 1746000000,
  "iat": 1745996400,
  "scope": "read:profile read:email",
  "client_id": "my-app-client-id"
}

// Usage in API request
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9...

The resource server validates the JWT signature using the authorization server's public key — no network call needed.

Long-lived credential used exclusively to obtain new access tokens when the current one expires. Never sent to resource servers.

// Refresh token exchange
const response = await fetch('https://auth.example.com/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'refresh_token',
    refresh_token: storedRefreshToken,
    client_id: 'YOUR_CLIENT_ID',
    // client_secret required for confidential clients
  }),
});

const { access_token, refresh_token } = await response.json();
// Note: authorization server may rotate the refresh token
// Always store the new one

Store refresh tokens securely. In web apps, use httpOnly cookies. In mobile apps, use the platform's secure storage (Keychain / Keystore).

OpenID Connect extension. When you request the openid scope, the authorization server also returns an ID Token — a JWT that contains identity claims about the authenticated user.

// Decoded ID token
{
  "iss": "https://auth.example.com",
  "sub": "user_12345",
  "aud": "my-app-client-id",
  "exp": 1746000000,
  "iat": 1745996400,
  "name": "Jane Doe",
  "email": "jane@example.com",
  "picture": "https://example.com/jane.jpg",
  "email_verified": true
}

// Verify ID token before trusting it
import { jwtVerify, createRemoteJWKSet } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json')
);

const { payload } = await jwtVerify(idToken, JWKS, {
  issuer: 'https://auth.example.com',
  audience: 'my-app-client-id',
});

Security Best Practices

OAuth 2.0 is secure by design, but many breaches come from incorrect implementation. Here are the critical security requirements every developer must follow.

1. Always Use PKCE for Public Clients

Any client that cannot securely store a client secret (SPAs, mobile apps, desktop apps) is a "public client." PKCE is not optional for these — it prevents authorization code interception attacks where a malicious app on the same device intercepts the redirect.

2. Validate the State Parameter

CSRF Protection with State Parameter
// Before redirect — generate and store state
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);

// In the authorization URL
const params = new URLSearchParams({
  // ...
  state,
});

// In the callback — validate state matches
function handleCallback() {
  const returnedState = new URLSearchParams(window.location.search).get('state');
  const savedState = sessionStorage.getItem('oauth_state');
  sessionStorage.removeItem('oauth_state');

  if (returnedState !== savedState) {
    throw new Error('State mismatch — possible CSRF attack');
  }
  // Continue with token exchange...
}

3. Strict Redirect URI Validation

Register exact redirect URIs with your authorization server — no wildcards, no partial matches. An open redirect vulnerability can allow an attacker to steal authorization codes by substituting a malicious redirect URI.

4. Store Tokens Securely

Storage Location Access Token Refresh Token Verdict
localStorage Accessible via JS Accessible via JS ⛔ XSS risk
sessionStorage Cleared on tab close Not recommended ⚠️ Still XSS risk
httpOnly Cookie Not readable by JS Secure storage ✅ Preferred for web
Memory (JS variable) Lost on page refresh Not practical ✅ Acceptable for access tokens
Keychain / Keystore N/A OS-managed secure storage ✅ Required for mobile

5. Validate JWT Signatures Server-Side

Express.js — JWT Validation Middleware
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { Request, Response, NextFunction } from 'express';

const JWKS = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json')
);

export async function requireAuth(
  req: Request,
  res: Response,
  next: NextFunction
) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing bearer token' });
  }

  const token = authHeader.slice(7);

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: 'https://auth.example.com',
      audience: 'https://api.example.com',
    });

    // Attach decoded claims to request
    req.user = payload;
    next();
  } catch (err) {
    return res.status(401).json({ error: 'Invalid or expired token' });
  }
}
Never Decode Without Verifying

JWT payloads are base64-encoded, not encrypted. Never trust a JWT by just decoding it — always verify the signature and validate the iss, aud, and exp claims. Skipping signature verification is one of the most common OAuth implementation mistakes.

Handling Token Refresh

Access tokens are short-lived by design. A robust OAuth implementation handles token refresh automatically, without requiring the user to log in again.

auth-client.js — Automatic Token Refresh
class AuthClient {
  #accessToken = null;
  #refreshToken = null;
  #tokenExpiry = null;
  #refreshPromise = null; // Prevent concurrent refreshes

  async fetch(url, options = {}) {
    const token = await this.#getValidToken();

    const response = await fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${token}`,
      },
    });

    // Handle token revocation mid-session
    if (response.status === 401) {
      this.#accessToken = null;
      throw new Error('Session expired. Please log in again.');
    }

    return response;
  }

  async #getValidToken() {
    // Token still valid with 30s buffer
    if (this.#accessToken && Date.now() < this.#tokenExpiry - 30000) {
      return this.#accessToken;
    }

    // Prevent concurrent refresh calls (race condition)
    if (!this.#refreshPromise) {
      this.#refreshPromise = this.#doRefresh().finally(() => {
        this.#refreshPromise = null;
      });
    }

    return this.#refreshPromise;
  }

  async #doRefresh() {
    if (!this.#refreshToken) throw new Error('No refresh token available');

    const res = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include', // Include httpOnly cookie with refresh token
    });

    if (!res.ok) throw new Error('Token refresh failed');

    const data = await res.json();
    this.#accessToken = data.access_token;
    this.#tokenExpiry = Date.now() + data.expires_in * 1000;
    return this.#accessToken;
  }
}

Key Takeaways

OAuth 2.0 Implementation Checklist

  • Use Authorization Code + PKCE for all user-facing applications (web, mobile, SPA)
  • Use Client Credentials for server-to-server communication without a user context
  • Always validate the state parameter to prevent CSRF attacks on the callback
  • Register exact redirect URIs — never use wildcards or partial matches
  • Store refresh tokens in httpOnly cookies on the web; use Keychain/Keystore on mobile
  • Verify JWT signatures on every request using the authorization server's JWKS endpoint
  • Cache access tokens until near-expiry; implement automatic silent refresh
  • Request minimal scopes — principle of least privilege applies to OAuth too
  • Never use Implicit or ROPC grants in new applications — they are deprecated in OAuth 2.1
  • Rotate refresh tokens on every use to limit the blast radius of a token theft

OAuth 2.0 vs OpenID Connect — Quick Reference

Aspect OAuth 2.0 OpenID Connect (OIDC)
Purpose Authorization (access delegation) Authentication (identity verification)
Primary token Access Token ID Token (+ Access Token)
User info Not included Claims in ID token or /userinfo
Required scope App-specific openid (+ profile, email)
Answers "Who is this?" No Yes
Answers "Can they access X?" Yes Yes (also built on OAuth 2.0)
Spec RFC 6749 OpenID Foundation spec
"OAuth 2.0 is a specification for building authorization flows, not a single protocol. Understanding which grant type to use — and implementing it correctly with PKCE and proper token storage — is what separates secure applications from vulnerable ones."

OAuth 2.0 underpins nearly every modern authentication system you encounter. Once you understand the core roles, token types, and how PKCE eliminates the main vulnerabilities of earlier approaches, you'll be well-equipped to implement secure delegated authorization in any application — whether it's a single-page app, a mobile client, or a microservices architecture talking to itself.

OAuth Authentication Security PKCE JWT Authorization
Mayur Dabhi

Mayur Dabhi

Full Stack Developer with 5+ years of experience building scalable web applications with Laravel, React, and Node.js.