OAuth2 Flows Explained

Authorization Code + PKCE, Client Credentials, and Refresh Token Rotation — with complete Node.js implementations

Last Updated:

What is OAuth2?

OAuth2 (RFC 6749) is an authorization framework — not an authentication protocol. It lets a user (Resource Owner) grant a third-party application (Client) limited access to their data on a server (Resource Server), without sharing their password.

The key distinction: OAuth2 answers "what are you allowed to do?" not "who are you?". For authentication (identity), you layer OpenID Connect (OIDC) on top of OAuth2, which adds an id_token and a /userinfo endpoint.

┌──────────────┐ ┌──────────────────┐ ┌───────────────┐ │ Resource │ │ Authorization │ │ Resource │ │ Owner │ │ Server │ │ Server │ │ (User) │ │ (Auth0, Okta, │ │ (Your API) │ └──────┬───────┘ │ Keycloak…) │ └───────┬───────┘ │ └────────┬─────────┘ │ │ ┌──────────────┐ │ │ └───►│ Client │◄───┘ │ │ (Your App) │──── access_token ────────────►│ └──────────────┘ │ │ Actors: Resource Owner · Client · Authorization Server · Resource Server

The Grant Types

OAuth2 defines several grant types (flows) for different use cases. In 2026, only three are recommended — the others are deprecated for security reasons:

Grant TypeUse CaseInvolves User?Status
Authorization Code + PKCE Web apps, mobile apps, SPAs — any user-facing client ✅ Yes ✅ Recommended
Client Credentials Machine-to-machine (M2M) — services, cron jobs, CI/CD ❌ No ✅ Recommended
Device Code Smart TVs, CLIs, devices with no browser ✅ Yes (on another device) ✅ Recommended
Refresh Token Silent token renewal (used alongside other grants) ✅ Use with rotation
Implicit Was used for SPAs — replaced by Auth Code + PKCE ✅ Yes ❌ Deprecated
Resource Owner Password Was used for first-party apps — avoid ✅ Yes ❌ Deprecated

Authorization Code Flow + PKCE

PKCE (Proof Key for Code Exchange, RFC 7636) is now mandatory for all public clients and strongly recommended for confidential clients too (OAuth 2.1 draft). It prevents authorization code interception attacks.

Why PKCE?

The classic Authorization Code flow had a vulnerability: if an attacker intercepts the authorization code (possible via malicious apps, URL handlers, or browser history), they can exchange it for tokens. PKCE closes this by requiring the client to prove it generated the original code:

Step 1 — Client generates a secret: code_verifier = random 32–96 bytes (base64url encoded) code_challenge = BASE64URL(SHA256(code_verifier)) Step 2 — Client sends code_challenge to Authorization Server in redirect: GET /authorize? response_type=code &client_id=CLIENT_ID &redirect_uri=https://app.example.com/callback &scope=openid profile email &state=RANDOM_STATE ← CSRF protection &code_challenge=CHALLENGE &code_challenge_method=S256 Step 3 — User authenticates, AS redirects back: https://app.example.com/callback?code=AUTH_CODE&state=RANDOM_STATE Step 4 — Client exchanges code + verifier for tokens: POST /token grant_type=authorization_code &code=AUTH_CODE &redirect_uri=https://app.example.com/callback &client_id=CLIENT_ID &code_verifier=ORIGINAL_VERIFIER ← AS hashes this and compares to challenge Step 5 — AS returns tokens: { "access_token": "...", "refresh_token": "...", "id_token": "..." }

Node.js Implementation (Express)

const express = require('express');
const crypto = require('crypto');
const { URLSearchParams } = require('url');

const app = express();
app.use(require('cookie-parser')());
app.use(express.json());

const AUTH_SERVER = 'https://auth.example.com';
const CLIENT_ID = process.env.OAUTH_CLIENT_ID;
const REDIRECT_URI = 'https://app.example.com/callback';

// ── Step 1: Initiate login ────────────────────────────────────────────
app.get('/login', (req, res) => {
  // Generate PKCE pair
  const verifier = crypto.randomBytes(32).toString('base64url');
  const challenge = crypto
    .createHash('sha256')
    .update(verifier)
    .digest('base64url');

  // CSRF protection
  const state = crypto.randomBytes(16).toString('hex');

  // Store in short-lived cookie (HttpOnly, Secure, SameSite=Lax)
  res.cookie('pkce_verifier', verifier, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 300_000 // 5 min
  });
  res.cookie('oauth_state', state, {
    httpOnly: true, secure: true, sameSite: 'lax', maxAge: 300_000
  });

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: CLIENT_ID,
    redirect_uri: REDIRECT_URI,
    scope: 'openid profile email',
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256'
  });

  res.redirect(`${AUTH_SERVER}/authorize?${params}`);
});

// ── Step 2: Handle callback ────────────────────────────────────────────
app.get('/callback', async (req, res) => {
  const { code, state } = req.query;

  // Validate state (CSRF check)
  if (!state || state !== req.cookies.oauth_state) {
    return res.status(400).send('Invalid state parameter');
  }

  const verifier = req.cookies.pkce_verifier;
  if (!verifier) return res.status(400).send('Missing PKCE verifier');

  // Clear PKCE cookies
  res.clearCookie('pkce_verifier');
  res.clearCookie('oauth_state');

  // Exchange code for tokens
  const tokenRes = await fetch(`${AUTH_SERVER}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: verifier
    })
  });

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

  const { access_token, refresh_token, expires_in } = await tokenRes.json();

  // Store refresh token in HttpOnly cookie, access token in memory / short cookie
  res.cookie('refresh_token', refresh_token, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: 30 * 24 * 60 * 60 * 1000 // 30 days
  });

  res.json({ access_token, expires_in });
});

Client Credentials Flow

The Client Credentials flow is for machine-to-machine (M2M) communication — there is no user involved. Services, cron jobs, and CI/CD pipelines use this to authenticate with APIs on their own behalf.

// Token manager with automatic caching and proactive refresh
class OAuth2TokenManager {
  #clientId;
  #clientSecret;
  #tokenUrl;
  #token = null;
  #expiresAt = 0;
  #refreshing = null;  // dedup concurrent refresh calls

  constructor({ clientId, clientSecret, tokenUrl, scope }) {
    this.#clientId = clientId;
    this.#clientSecret = clientSecret;
    this.#tokenUrl = tokenUrl;
    this.scope = scope;
  }

  async getToken() {
    // Refresh 60s before expiry (proactive refresh)
    if (this.#token && Date.now() < this.#expiresAt - 60_000) {
      return this.#token;
    }

    // Deduplicate concurrent calls
    if (!this.#refreshing) {
      this.#refreshing = this.#fetchToken().finally(() => {
        this.#refreshing = null;
      });
    }
    return this.#refreshing;
  }

  async #fetchToken() {
    const res = await fetch(this.#tokenUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: this.#clientId,
        client_secret: this.#clientSecret,
        scope: this.scope
      })
    });

    if (!res.ok) throw new Error(`Token fetch failed: ${res.status}`);
    const { access_token, expires_in } = await res.json();

    this.#token = access_token;
    this.#expiresAt = Date.now() + expires_in * 1000;
    return this.#token;
  }
}

// Usage in a service
const tokenManager = new OAuth2TokenManager({
  clientId: process.env.CLIENT_ID,
  clientSecret: process.env.CLIENT_SECRET,
  tokenUrl: 'https://auth.example.com/oauth/token',
  scope: 'orders:read inventory:write'
});

async function callInventoryAPI(productId) {
  const token = await tokenManager.getToken();
  return fetch(`https://api.example.com/inventory/${productId}`, {
    headers: { Authorization: `Bearer ${token}` }
  });
}
Security note: Never store client_secret in client-side code, environment variables committed to git, or Docker images. Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, or a Kubernetes secret).

Refresh Token Rotation

Refresh tokens allow users to stay logged in without re-entering credentials. Rotation means issuing a new refresh token every time the old one is used — and immediately invalidating the old one. This enables reuse detection: if a stolen refresh token is used after the legitimate client has already rotated it, the server detects the duplicate and revokes the entire token family.

const express = require('express');
const crypto = require('crypto');
const redis = require('redis');

const app = express();
const cache = redis.createClient();

// ── Refresh token rotation endpoint ────────────────────────────────────
app.post('/auth/refresh', async (req, res) => {
  const incomingRefreshToken = req.cookies.refresh_token;
  if (!incomingRefreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }

  const tokenKey = `rt:${incomingRefreshToken}`;
  const tokenData = await cache.get(tokenKey);

  if (!tokenData) {
    // Token not found — either expired or already used (possible theft)
    // Check if this token was previously rotated (reuse detection)
    const reuseKey = `rt:rotated:${incomingRefreshToken}`;
    const wasRotated = await cache.get(reuseKey);

    if (wasRotated) {
      // REUSE DETECTED: revoke entire token family
      const { familyId } = JSON.parse(wasRotated);
      await revokeTokenFamily(familyId);
      return res.status(401).json({ error: 'Refresh token reuse detected — please log in again' });
    }

    return res.status(401).json({ error: 'Invalid or expired refresh token' });
  }

  const { userId, familyId, scope } = JSON.parse(tokenData);

  // Issue new access token
  const newAccessToken = generateAccessToken(userId, scope);

  // Issue new refresh token
  const newRefreshToken = crypto.randomBytes(32).toString('base64url');
  const refreshTTL = 30 * 24 * 60 * 60; // 30 days

  // Atomically: store new token, mark old as rotated, delete old active entry
  await Promise.all([
    cache.setEx(`rt:${newRefreshToken}`, refreshTTL,
      JSON.stringify({ userId, familyId, scope })),
    cache.setEx(`rt:rotated:${incomingRefreshToken}`, refreshTTL,
      JSON.stringify({ familyId })),        // keep for reuse detection
    cache.del(tokenKey)                     // invalidate old token
  ]);

  // Return new refresh token in HttpOnly cookie
  res.cookie('refresh_token', newRefreshToken, {
    httpOnly: true, secure: true, sameSite: 'strict',
    maxAge: refreshTTL * 1000
  });

  res.json({ access_token: newAccessToken, expires_in: 900 });
});

async function revokeTokenFamily(familyId) {
  // In production: scan for all tokens with this familyId and delete them
  // Simpler: store revoked family IDs in a set with TTL
  await cache.setEx(`family:revoked:${familyId}`, 30 * 24 * 60 * 60, '1');
}

JWT vs Opaque Tokens

OAuth2 does not mandate any particular token format. The two common choices are JWT (self-contained) and opaque (reference) tokens:

PropertyJWT (Self-Contained)Opaque Token
VerificationSignature check only — no network callIntrospection endpoint call required
RevocationHard — must wait for expiry or use a block listEasy — delete from store
PayloadClaims visible to anyone with the tokenOpaque — no leaked data
Scalability✅ Excellent — stateless, no DB per requestRequires shared token store
Token size~300–500 bytes~32–64 bytes
Best forMicroservices, API gateways, high throughputSessions requiring instant revocation

For most REST APIs, JWTs are the right choice for access tokens (short-lived, stateless verification). Use opaque tokens for refresh tokens stored in your database or Redis where instant revocation matters.

Scopes and Claims

Scopes define the permissions the client is requesting. Claims are key-value pairs inside the JWT payload carrying context about the token.

// Scope definition: coarse-grained
const SCOPES = {
  'orders:read':   'Read your orders',
  'orders:write':  'Create and update orders',
  'profile:read':  'Read your profile information',
  'admin':         'Full administrative access'
};

// JWT payload (claims) — what you'll verify on your API
{
  "iss": "https://auth.example.com",       // issuer
  "sub": "user_abc123",                    // subject (user ID)
  "aud": "https://api.example.com",        // audience (your API)
  "exp": 1715000000,                       // expiry (Unix timestamp)
  "iat": 1714999100,                       // issued at
  "scope": "orders:read profile:read",     // granted scopes
  "email": "alice@example.com",
  "org_id": "org_xyz"                      // custom claim
}

// Middleware: verify JWT + check scope
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

const client = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
  cache: true, cacheMaxAge: 3600_000
});

function requireScope(requiredScope) {
  return async (req, res, next) => {
    const authHeader = req.headers.authorization || '';
    const token = authHeader.replace('Bearer ', '');
    if (!token) return res.status(401).json({ error: 'Missing token' });

    try {
      const decoded = await verifyJWT(token);
      const grantedScopes = (decoded.scope || '').split(' ');

      if (!grantedScopes.includes(requiredScope)) {
        return res.status(403).json({
          error: 'insufficient_scope',
          required: requiredScope
        });
      }

      req.user = decoded;
      next();
    } catch (err) {
      res.status(401).json({ error: 'Invalid token', message: err.message });
    }
  };
}

// Usage
app.get('/orders', requireScope('orders:read'), listOrders);
app.post('/orders', requireScope('orders:write'), createOrder);

OAuth2 Security Checklist

  • Always use PKCE for public clients (SPAs, mobile apps)
  • Validate state parameter on every callback to prevent CSRF
  • Verify iss, aud, exp, and nbf claims on every JWT
  • Use short-lived access tokens (5–15 minutes)
  • Store refresh tokens in HttpOnly, Secure, SameSite=Strict cookies — never localStorage
  • Implement refresh token rotation with reuse detection
  • Use HTTPS everywhere — never OAuth2 over HTTP
  • Register exact redirect URIs — never allow wildcards
  • Rotate client secrets periodically
  • Use JWKS endpoint for key rotation (never hardcode public keys)
  • Avoid Implicit flow — use Authorization Code + PKCE instead
  • Never use Resource Owner Password Credentials (ROPC)
  • Never store client_secret in client-side code or git repos

Frequently Asked Questions

What is the difference between OAuth2 and JWT?

OAuth2 is an authorization framework (a protocol for granting access). JWT is a token format. They are complementary: OAuth2 defines the flows and endpoints for obtaining tokens, while JWT is one format those tokens can take. You can use OAuth2 without JWTs (opaque tokens) or use JWTs without OAuth2.

Why is PKCE required for public clients?

Public clients (SPAs, mobile apps) cannot securely store a client secret because their code is exposed to the user. PKCE replaces the client secret with a dynamically generated code_verifier and code_challenge pair, preventing authorization code interception attacks without needing a shared secret.

When should I use Client Credentials vs Authorization Code flow?

Use Client Credentials for machine-to-machine communication where there is no human user — cron jobs, microservices, CI/CD pipelines. Use Authorization Code + PKCE when a human user delegates access — web apps, mobile apps, third-party integrations.

How long should OAuth2 access tokens be valid?

Access tokens should be short-lived: 5–15 minutes for high-security APIs, up to 1 hour for standard use cases. Short expiry limits the blast radius if a token is stolen. Pair short access tokens with longer-lived refresh tokens (7–90 days) stored in HttpOnly cookies.

What is refresh token rotation?

Refresh token rotation means issuing a new refresh token every time the old one is used. The old token is immediately invalidated. This provides reuse detection: if an attacker uses a stolen refresh token after the legitimate client already rotated it, the server detects the reuse and revokes the entire token family.

Related Topics

For a broader overview of authentication methods including API keys and JWT basics, see our Authentication guide. OAuth2 tokens at the API gateway layer enable centralized introspection for all microservices. For microservices calling each other, use the Client Credentials flow to obtain service-scoped tokens.