OAuth 2.0 Complete Guide 2026: Authorization Code, PKCE & Client Credentials Explained

OAuth 2.0 is the foundation of every “Sign in with Google” button, every GitHub integration, and every enterprise SSO you have ever used. Yet it is also one of the most misunderstood protocols in web development — confused with authentication, conflated with JWT, and implemented incorrectly in ways that create serious security vulnerabilities. This guide covers exactly what OAuth 2.0 is, how each grant type works and when to use it, a complete Authorization Code + PKCE implementation in Node.js, the client credentials flow for machine-to-machine APIs, token refresh patterns, and how OAuth relates to JWT and OpenID Connect.

4

OAuth 2.0 grant types — Authorization Code, Client Credentials, Device Code, Refresh

84%

of enterprise apps use OAuth 2.0 as their federated identity standard in 2026

PKCE

is now mandatory for all public clients — Authorization Code without PKCE is deprecated

0

passwords shared with third-party apps when OAuth is implemented correctly

1

Definition: What Is OAuth 2.0?

OAuth 2.0 = a delegated authorization framework — not authentication

OAuth 2.0 is a framework (RFC 6749) that lets a user grant a third-party applicationlimited access to their resources on another service — without sharing their password. When you click “Continue with GitHub” and your app reads your repos, that is OAuth 2.0. Critically: OAuth 2.0 is about authorization (what you can access), notauthentication (who you are). OpenID Connect (OIDC) is the authentication layer built on top of OAuth 2.0 — the two work together.

Four parties are involved in every OAuth 2.0 flow: the resource owner (the user), the client (your app), the authorization server (Google, GitHub, your IdP), and the resource server (the API being accessed). OAuth defines how these four parties exchange credentials safely — the user authorizes, the authorization server issues tokens, and the client uses tokens to access resources — all without the client ever seeing the user's password.

2

When to Use OAuth 2.0

✅ Third-party login ("Sign in with Google")

When users should log into your app using an identity they already have — Google, GitHub, Microsoft, Apple. OAuth + OIDC handles the full flow: authorization, token exchange, and user profile retrieval. Your app never handles passwords.

✅ Accessing user data on another service

Your app needs to read a user's GitHub repos, post to their Twitter, or read their Google Calendar. OAuth lets the user grant specific scopes of access without giving your app their full account credentials.

✅ Enterprise SSO and federated identity

Large organizations use a central identity provider (Okta, Azure AD, Auth0). OAuth + OIDC lets your app delegate authentication to that IdP. Users get one set of credentials across all enterprise tools.

✅ Service-to-service API access (Client Credentials)

Your backend microservice needs to call another protected API — no user involved. The Client Credentials grant handles machine-to-machine authorization: service authenticates with client_id + client_secret, gets an access token, calls the API.

❌ When you own both the client and the resource server

If your app and your API are the same organization, OAuth adds complexity without benefit. Use JWT directly, API keys, or session cookies. OAuth is designed for cross-organization authorization boundaries.

❌ Simple API key authentication

For developer-facing APIs where each developer gets a static API key, OAuth is overkill. API keys are simpler to implement, easier to rotate, and sufficient for many use cases. Use OAuth when you need delegated, scoped, time-limited access.

3

OAuth 2.0 vs JWT vs API Keys — Decision Chart

FactorOAuth 2.0JWT (direct)API Keys
What it isAuthorization frameworkToken formatSimple credential string
PurposeDelegated access across org boundariesStateless auth within your systemMachine-to-machine identification
User involvementUser grants consentUser authenticates directlyNo user — developer gets key
Token formatOften JWT (access_token)Always JWTOpaque string
Scopes/permissionsFine-grained OAuth scopesClaims in payloadAll-or-nothing (or key-per-scope)
Revocation✅ Revoke at auth server❌ Needs blocklist✅ Delete key from database
ComplexityHigh — multiple parties/flowsMedium — sign/verifyLow — just a lookup
Best for"Login with Google", federated SSOYour own auth system, microservicesDeveloper APIs, webhooks, automation

OAuth 2.0 access tokens are usually JWT — but they don't have to be

When Google or GitHub issues an OAuth access token, it is often (but not always) a JWT internally. As an OAuth client, you treat the access token as an opaque string — you send it in the Authorization header and let the resource server validate it. Only the authorization server and resource server care about the token format. Your app just stores and forwards it.
4

How OAuth 2.0 Works — The Authorization Code + PKCE Flow

Authorization Code with PKCE (Proof Key for Code Exchange, RFC 7636) is the correct grant type for any application where the client secret cannot be kept truly secret — which means all single-page apps, mobile apps, and desktop apps. As of 2026, PKCE is required for all public clients per OAuth 2.0 Security Best Practices (RFC 9700).

Authorization Code + PKCE Flow — Step by Step

Generate PKCE pair

App generates: code_verifier (32 random bytes, base64url) and code_challenge = SHA-256(code_verifier) base64url. Store code_verifier in memory.

Redirect to auth server

User clicks "Login with Google". App redirects to: /authorize?client_id=&response_type=code&code_challenge=<hash>&code_challenge_method=S256&scope=openid+email&state=<random>

User authenticates

User logs in at Google's own login page. Your app never sees their password. User approves requested scopes.

Auth code returned

Authorization server redirects back to your redirect_uri with: ?code=<auth_code>&state=<verify_state_matches>

Exchange code for tokens

POST to /token with: code, client_id, redirect_uri, code_verifier. Server verifies SHA-256(code_verifier) == code_challenge from step 1.

Tokens received

Response: { access_token, refresh_token, id_token (OIDC), expires_in, token_type: "Bearer", scope }

Call protected API

Authorization: Bearer <access_token> on every API request. Token expires in 1 hour typically.

Silent refresh

When access token expires, use refresh_token to get a new one silently. User sees no interruption.

javascriptoauth-pkce.js — complete Authorization Code + PKCE implementation
import crypto from 'crypto';

// ── Step 1: Generate PKCE values ───────────────────────────────────────────
function generatePKCE() {
  const codeVerifier  = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');
  return { codeVerifier, codeChallenge };
}

// ── Step 2: Build authorization URL ───────────────────────────────────────
function buildAuthUrl(codeChallenge, state) {
  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 — verify on callback
    code_challenge:         codeChallenge,
    code_challenge_method:  'S256',
  });
  return 'https://accounts.google.com/o/oauth2/auth?' + params.toString();
}

// ── Login handler — redirects user to authorization server ─────────────────
app.get('/auth/login', (req, res) => {
  const { codeVerifier, codeChallenge } = generatePKCE();
  const state = crypto.randomBytes(16).toString('hex');

  // Store both in session — needed on callback
  req.session.codeVerifier = codeVerifier;
  req.session.oauthState   = state;

  res.redirect(buildAuthUrl(codeChallenge, state));
});

// ── Callback handler — exchanges auth code for tokens ─────────────────────
app.get('/auth/callback', async (req, res) => {
  const { code, state, error } = req.query;

  if (error) return res.status(400).json({ error });

  // Verify CSRF state
  if (state !== req.session.oauthState) {
    return res.status(400).json({ error: 'State mismatch — possible CSRF attack' });
  }

  // Exchange authorization code for tokens
  const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
    method:  'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body:    new URLSearchParams({
      grant_type:    'authorization_code',
      client_id:     process.env.OAUTH_CLIENT_ID,
      client_secret: process.env.OAUTH_CLIENT_SECRET,
      redirect_uri:  'https://myapp.com/auth/callback',
      code,
      code_verifier: req.session.codeVerifier, // must match the challenge from step 1
    }).toString(),
  });

  if (!tokenRes.ok) {
    const err = await tokenRes.json();
    return res.status(400).json({ error: 'Token exchange failed', details: err });
  }

  const tokens = await tokenRes.json();
  // tokens: { access_token, refresh_token, id_token, expires_in, scope }

  // Store refresh token securely (HttpOnly cookie or encrypted DB column)
  req.session.refreshToken = tokens.refresh_token;
  req.session.accessToken  = tokens.access_token;

  // Fetch user profile with access token
  const profile = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
    headers: { Authorization: 'Bearer ' + tokens.access_token },
  }).then(r => r.json());

  // profile: { sub, email, name, picture, email_verified }
  await upsertUser({ googleId: profile.sub, email: profile.email, name: profile.name });

  res.redirect('/dashboard');
});
5

How — Client Credentials Grant (Machine-to-Machine)

The Client Credentials grant is for server-to-server communication — no user is involved. Your backend service authenticates directly with the authorization server using its own credentials (client_id + client_secret) and receives an access token scoped to the service's own permissions.

javascriptclient-credentials.js — service-to-service OAuth token
// ── Get a service access token ─────────────────────────────────────────────
// Cache this token in memory — reuse until expiry, then fetch a new one
let cachedToken = null;
let tokenExpiry  = 0;

async function getServiceToken() {
  if (cachedToken && Date.now() < tokenExpiry - 60_000) {
    return cachedToken; // return cached token with 60s buffer
  }

  const res = await fetch('https://auth.myplatform.com/oauth/token', {
    method:  'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body:    new URLSearchParams({
      grant_type:    'client_credentials',
      client_id:     process.env.SERVICE_CLIENT_ID,
      client_secret: process.env.SERVICE_CLIENT_SECRET,
      scope:         'read:orders write:fulfillment', // only what this service needs
    }).toString(),
  });

  if (!res.ok) throw new Error('Failed to get service token: ' + res.status);

  const data = await res.json();
  // { access_token, token_type: "Bearer", expires_in: 3600, scope }

  cachedToken = data.access_token;
  tokenExpiry  = Date.now() + data.expires_in * 1000;

  return cachedToken;
}

// ── Use the token to call a protected internal API ─────────────────────────
async function fetchOrder(orderId) {
  const token = await getServiceToken();

  const res = await fetch('https://orders-api.internal/orders/' + orderId, {
    headers: {
      Authorization:  'Bearer ' + token,
      'Content-Type': 'application/json',
    },
  });

  if (!res.ok) throw new Error('Order API error: ' + res.status);
  return res.json();
}

// No refresh_token with client credentials — just request a new access token when it expires
6

How — Silent Token Refresh with Refresh Tokens

javascripttoken-refresh.js — transparent access token renewal
// ── Refresh access token silently ──────────────────────────────────────────
async function refreshAccessToken(refreshToken) {
  const res = await fetch('https://oauth2.googleapis.com/token', {
    method:  'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body:    new URLSearchParams({
      grant_type:    'refresh_token',
      client_id:     process.env.OAUTH_CLIENT_ID,
      client_secret: process.env.OAUTH_CLIENT_SECRET,
      refresh_token: refreshToken,
    }).toString(),
  });

  if (!res.ok) {
    // Refresh token expired or revoked — user must re-authenticate
    throw new Error('REFRESH_FAILED');
  }

  const data = await res.json();
  // { access_token, expires_in, scope, token_type }
  // Note: Google does NOT return a new refresh_token here — keep the original
  // Some providers DO rotate refresh tokens — always check the response

  return { accessToken: data.access_token, expiresIn: data.expires_in };
}

// ── Wrapper that auto-refreshes on 401 ────────────────────────────────────
async function oauthFetch(url, options, session) {
  let res = await fetch(url, {
    ...options,
    headers: { ...options.headers, Authorization: 'Bearer ' + session.accessToken },
  });

  if (res.status === 401) {
    try {
      const { accessToken } = await refreshAccessToken(session.refreshToken);
      session.accessToken = accessToken;
      // Retry with new token
      res = await fetch(url, {
        ...options,
        headers: { ...options.headers, Authorization: 'Bearer ' + session.accessToken },
      });
    } catch {
      // Refresh failed — session expired, redirect to login
      throw new Error('SESSION_EXPIRED');
    }
  }

  return res;
}
7

Why OAuth 2.0 — The Security Model

1

Users never share passwords with third-party apps

The core security property of OAuth: the user authenticates directly with the authorization server (Google's own login page). Your app only receives a time-limited, scoped access token. If your app is compromised, the attacker gets the token — not the user's Google password.

2

Scopes limit what a token can do

OAuth tokens are granted specific scopes: read:profile, write:calendar, read:email. A token with read:profile cannot modify calendar data. Users can see and revoke specific scope grants. Principle of least privilege is built into the protocol.

3

Access tokens expire — refresh tokens provide continuity

Short-lived access tokens (1 hour typical) limit the damage window of a stolen token. Long-lived refresh tokens are stored securely server-side. When access expires, the client transparently gets a new one — the user sees no interruption.

4

PKCE prevents authorization code interception

Without PKCE, an attacker who intercepts the authorization code (via a malicious redirect URI or browser history) can exchange it for tokens. PKCE binds the auth code to the initial request via a cryptographic challenge that only the originating client can answer.

5

State parameter prevents CSRF attacks

The state parameter is a random nonce generated before the redirect and verified on callback. Without it, an attacker can trick a user into completing an OAuth flow that connects the attacker's account to the victim's session. Always generate, store, and verify state.

5 OAuth 2.0 implementation mistakes that create vulnerabilities

1. No state parameter — enables CSRF attacks that hijack OAuth flows. 2. No PKCE for public clients — authorization code interception becomes trivially exploitable. 3. Overly broad scopes — request only what you need; users distrust broad scope requests. 4. client_secret in frontend code — public clients have no secrets; use PKCE instead. 5. Not validating redirect_uri — authorization servers must strictly match registered URIs; open redirectors let attackers steal codes.

OAuth token endpoint responses, userinfo payloads, and resource server errors all return JSON. Paste any malformed response into our AI Error Explainer for instant diagnosis.

Debug OAuth JSON →

Frequently Asked Questions