Skip to content

OAuth 2.0 Flows Demystified

Authorization Code, PKCE, Client Credentials — which OAuth flow to use for which application type, and the security pitfalls hiding in each one.

3 min read
OAuth 2.0 authorization code flow sequence diagram between client, auth server, and resource server

OAuth 2.0 is the standard for delegated authorization, but its specification is confusingly flexible. Multiple grant types, optional parameters, and vendor-specific extensions make it hard to know which flow is correct for your application. Most security vulnerabilities in OAuth implementations come from choosing the wrong flow or skipping critical validation steps.

The Four Flows That Matter

OAuth 2.0 defines several grant types. In practice, four cover every modern use case.

FlowUse caseClient typeInvolves user?
Authorization Code + PKCEWeb apps, mobile apps, SPAsPublicYes
Client CredentialsService-to-serviceConfidentialNo
Device AuthorizationSmart TVs, CLI toolsPublicYes
Refresh TokenExtending session lifetimeBothNo (post-auth)

The Implicit flow and Resource Owner Password flow are deprecated. If you're using either, migrate now.

Authorization Code with PKCE

This is the recommended flow for any application where a user logs in. PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks.

tstypescript
// Step 1: Generate PKCE challenge
import crypto from "crypto";
 
function generatePKCE() {
  const verifier = crypto.randomBytes(32).toString("base64url");
  const challenge = crypto
    .createHash("sha256")
    .update(verifier)
    .digest("base64url");
 
  return { verifier, challenge };
}
 
// Step 2: Redirect user to authorization server
function getAuthorizationUrl(pkce: { challenge: string }) {
  const params = new URLSearchParams({
    response_type: "code",
    client_id: process.env.OAUTH_CLIENT_ID!,
    redirect_uri: "https://myapp.com/callback",
    scope: "openid profile email",
    state: crypto.randomBytes(16).toString("hex"),
    code_challenge: pkce.challenge,
    code_challenge_method: "S256",
  });
 
  return `https://auth.provider.com/authorize?${params}`;
}
tstypescript
// Step 3: Exchange authorization code for tokens
async function exchangeCode(code: string, verifier: string) {
  const response = await fetch("https://auth.provider.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: "https://myapp.com/callback",
      client_id: process.env.OAUTH_CLIENT_ID!,
      code_verifier: verifier, // PKCE verification
    }),
  });
 
  return response.json();
  // { access_token: "...", refresh_token: "...", id_token: "...", expires_in: 3600 }
}

The state parameter prevents CSRF attacks. The code_verifier proves that the same client that started the flow is completing it.

Client Credentials Flow

For service-to-service communication where no user is involved. The client authenticates with its own credentials.

tstypescript
// ✅ Service-to-service authentication
async function getServiceToken() {
  const response = await fetch("https://auth.provider.com/token", {
    method: "POST",
    headers: {
      "Content-Type": "application/x-www-form-urlencoded",
      Authorization: `Basic ${Buffer.from(
        `${process.env.CLIENT_ID}:${process.env.CLIENT_SECRET}`,
      ).toString("base64")}`,
    },
    body: new URLSearchParams({
      grant_type: "client_credentials",
      scope: "api:read api:write",
    }),
  });
 
  return response.json();
}
 
// Cache the token until near expiry
let cachedToken: { token: string; expiresAt: number } | null = null;
 
async function getValidToken(): Promise<string> {
  if (cachedToken && cachedToken.expiresAt > Date.now() + 60000) {
    return cachedToken.token;
  }
 
  const data = await getServiceToken();
  cachedToken = {
    token: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };
 
  return cachedToken.token;
}

Never expose client secrets in frontend code. Client Credentials is exclusively for server-side applications.

Common Security Mistakes

tstypescript
// ❌ Not validating the state parameter — CSRF vulnerability
app.get("/callback", async (req, res) => {
  const { code } = req.query;
  const tokens = await exchangeCode(code); // Missing state check!
});
 
// ✅ Always validate state
app.get("/callback", async (req, res) => {
  const { code, state } = req.query;
 
  const savedState = req.session.oauthState;
  if (!state || state !== savedState) {
    return res.status(403).json({ error: "Invalid state parameter" });
  }
 
  delete req.session.oauthState;
  const tokens = await exchangeCode(code, req.session.pkceVerifier);
});
tstypescript
// ❌ Not validating the ID token
const user = jwt.decode(tokens.id_token); // Decode without verify!
 
// ✅ Verify the ID token signature, issuer, and audience
const user = jwt.verify(tokens.id_token, publicKey, {
  issuer: "https://auth.provider.com",
  audience: process.env.OAUTH_CLIENT_ID,
  algorithms: ["RS256"],
});

Token Storage and Lifecycle

tstypescript
// Access token: short-lived, used for API calls
// Refresh token: long-lived, used to get new access tokens
 
async function refreshAccessToken(refreshToken: string) {
  const response = await fetch("https://auth.provider.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "refresh_token",
      refresh_token: refreshToken,
      client_id: process.env.OAUTH_CLIENT_ID!,
    }),
  });
 
  if (!response.ok) {
    // Refresh token expired or revoked — user must re-authenticate
    throw new AuthenticationError("Session expired");
  }
 
  return response.json();
}
TokenLifetimeStorageRefresh strategy
Access token15-60 minutesMemory or HTTP-only cookieUse refresh token
Refresh token7-30 daysHTTP-only cookie or secure server-sideRe-authenticate on expiry
ID tokenMatch access tokenMemoryNot refreshed — re-fetch on access token refresh

Key Takeaways

  1. Use Authorization Code + PKCE for any user-facing application — web, mobile, or SPA
  2. Client Credentials is for service-to-service only — never expose client secrets to browsers
  3. Always validate the state parameter — skipping it opens a CSRF vulnerability
  4. Verify ID token signatures — decoding without verification trusts unsigned data
  5. Short-lived access tokens + refresh rotation limits the damage window of a stolen token
  6. The Implicit flow is deprecated — migrate to Authorization Code + PKCE
Wilfredo Rujel

Wilfredo Rujel

Full Stack Software Engineer

Share this postX