MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

OAuth 2.0 Flows: Authorization Code, Client Credentials, and More

Understand OAuth 2.0 grant types: when to use each flow, security considerations, and PKCE.

OAuthSecurityAuthenticationAPI

By MinhVo

Introduction

OAuth 2.0 is the industry-standard protocol for authorization, but it is also one of the most misunderstood. Developers frequently confuse authentication with authorization, implement the wrong grant type for their use case, or introduce security vulnerabilities by cutting corners. A single mistake in your OAuth implementation can expose user data, compromise API keys, or allow token theft.

The OAuth 2.0 specification defines several grant types, each designed for a specific scenario. The Authorization Code grant is for web applications with a server component. The Client Credentials grant is for machine-to-machine communication. The Implicit grant was designed for browser-based applications but is now deprecated. The Device Authorization grant handles input-constrained devices like smart TVs.

Understanding which flow to use and how to implement it correctly is critical for building secure applications. This guide breaks down every OAuth 2.0 flow, explains when to use each one, covers security considerations like PKCE and token storage, and provides implementation examples with real code.

OAuth 2.0 security architecture

Understanding OAuth 2.0: Core Concepts

The OAuth 2.0 Model

OAuth 2.0 defines four roles: the resource owner (the user), the client (the application requesting access), the authorization server (issues tokens), and the resource server (hosts protected resources). The fundamental flow is: the client requests authorization from the resource owner, receives an authorization grant, exchanges it for an access token, and uses the token to access protected resources.

This model separates authentication from authorization. The client never sees the user credentials. Instead, the authorization server validates the credentials and issues tokens that grant limited access to specific resources for a specific duration.

Access Tokens and Refresh Tokens

An access token is a credential that represents the authorization granted to the client. It is typically a JWT (JSON Web Token) containing claims about the user and the scope of access. Access tokens are short-lived, usually 15 minutes to 1 hour.

A refresh token is a long-lived credential used to obtain new access tokens without requiring the user to re-authenticate. Refresh tokens are typically opaque strings stored securely on the server side. They can be revoked to invalidate all associated access tokens.

// JWT access token structure
{
  "header": {
    "alg": "RS256",
    "typ": "JWT",
    "kid": "key-id-123"
  },
  "payload": {
    "sub": "user-123",
    "iss": "https://auth.example.com",
    "aud": "https://api.example.com",
    "exp": 1609459200,
    "iat": 1609455600,
    "scope": "read write",
    "email": "user@example.com"
  }
}

Scopes

Scopes define the specific permissions that the client is requesting. They are space-delimited strings that the authorization server presents to the user during the consent screen. Common scopes include openid, profile, email, read, write, and admin.

// Authorization request with scopes
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('client_id', 'my-client-id');
authUrl.searchParams.set('redirect_uri', 'https://app.example.com/callback');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', generateState());

OAuth 2.0 vs OpenID Connect

OAuth 2.0 is an authorization protocol. OpenID Connect (OIDC) is an authentication layer built on top of OAuth 2.0. OIDC adds an ID token (a JWT containing user identity claims) and a UserInfo endpoint that returns profile information. If you need to authenticate users (know who they are), use OIDC. If you only need to authorize access (know what they can do), use OAuth 2.0.

OAuth flow diagram

Architecture and Design Patterns

Authorization Code Grant

The Authorization Code grant is the most secure and widely used OAuth 2.0 flow. It is designed for applications that have a server component where the client secret can be stored securely.

The flow works as follows: the client redirects the user to the authorization server, the user authenticates and consents, the authorization server redirects back with an authorization code, and the client exchanges the code for tokens on the server side.

// Step 1: Generate authorization URL
function getAuthorizationUrl(): string {
  const state = crypto.randomBytes(32).toString('hex');
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');
  
  // Store state and codeVerifier in session
  session.oauthState = state;
  session.codeVerifier = codeVerifier;
  
  const url = new URL('https://auth.example.com/authorize');
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', process.env.CLIENT_ID);
  url.searchParams.set('redirect_uri', 'https://app.example.com/callback');
  url.searchParams.set('scope', 'openid profile email');
  url.searchParams.set('state', state);
  url.searchParams.set('code_challenge', codeChallenge);
  url.searchParams.set('code_challenge_method', 'S256');
  
  return url.toString();
}
 
// Step 2: Handle callback and exchange code for tokens
async function handleCallback(code: string, state: string) {
  if (state !== session.oauthState) {
    throw new Error('Invalid state parameter');
  }
  
  const tokenResponse = 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://app.example.com/callback',
      client_id: process.env.CLIENT_ID,
      client_secret: process.env.CLIENT_SECRET,
      code_verifier: session.codeVerifier,
    }),
  });
  
  const tokens = await tokenResponse.json();
  // tokens.access_token, tokens.refresh_token, tokens.id_token
  return tokens;
}

Client Credentials Grant

The Client Credentials grant is used for machine-to-machine communication where there is no user involved. The client authenticates directly with the authorization server using its own credentials.

async function getClientToken(): Promise<string> {
  const response = await fetch('https://auth.example.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',
    }),
  });
  
  const data = await response.json();
  return data.access_token;
}

Device Authorization Grant

The Device Authorization grant is designed for input-constrained devices like smart TVs, gaming consoles, and CLI tools. The device displays a code and URL, the user visits the URL on another device and enters the code, and the device polls the authorization server until the user completes authorization.

async function startDeviceFlow() {
  const response = await fetch('https://auth.example.com/device/code', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: process.env.CLIENT_ID,
      scope: 'openid profile',
    }),
  });
  
  const data = await response.json();
  // data.device_code, data.user_code, data.verification_uri, data.expires_in, data.interval
  
  console.log(`Visit ${data.verification_uri} and enter code: ${data.user_code}`);
  
  // Poll for token
  while (true) {
    await sleep(data.interval * 1000);
    
    const tokenResponse = await fetch('https://auth.example.com/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
        device_code: data.device_code,
        client_id: process.env.CLIENT_ID,
      }),
    });
    
    const tokens = await tokenResponse.json();
    
    if (tokens.access_token) {
      return tokens;
    }
    
    if (tokens.error === 'authorization_pending') {
      continue;
    }
    
    if (tokens.error === 'slow_down') {
      data.interval += 5;
      continue;
    }
    
    throw new Error(`Device flow failed: ${tokens.error}`);
  }
}

Step-by-Step Implementation

Complete Authorization Code Flow with Express

import express from 'express';
import session from 'express-session';
import crypto from 'crypto';
 
const app = express();
app.use(session({ secret: 'session-secret', resave: false, saveUninitialized: true }));
 
const AUTH_SERVER = 'https://auth.example.com';
const CLIENT_ID = process.env.CLIENT_ID;
const CLIENT_SECRET = process.env.CLIENT_SECRET;
const REDIRECT_URI = 'http://localhost:3000/callback';
 
// Login: redirect to authorization server
app.get('/login', (req, res) => {
  const state = crypto.randomBytes(32).toString('hex');
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
  
  req.session.oauthState = state;
  req.session.codeVerifier = codeVerifier;
  
  const url = new URL(`${AUTH_SERVER}/authorize`);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', CLIENT_ID);
  url.searchParams.set('redirect_uri', REDIRECT_URI);
  url.searchParams.set('scope', 'openid profile email');
  url.searchParams.set('state', state);
  url.searchParams.set('code_challenge', codeChallenge);
  url.searchParams.set('code_challenge_method', 'S256');
  
  res.redirect(url.toString());
});
 
// Callback: exchange code for tokens
app.get('/callback', async (req, res) => {
  const { code, state, error } = req.query;
  
  if (error) {
    return res.status(400).json({ error: req.query.error_description });
  }
  
  if (state !== req.session.oauthState) {
    return res.status(403).json({ error: 'Invalid state' });
  }
  
  try {
    const tokenResponse = await fetch(`${AUTH_SERVER}/token`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: code as string,
        redirect_uri: REDIRECT_URI,
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET,
        code_verifier: req.session.codeVerifier,
      }),
    });
    
    const tokens = await tokenResponse.json();
    
    if (tokens.error) {
      return res.status(400).json({ error: tokens.error_description });
    }
    
    req.session.accessToken = tokens.access_token;
    req.session.refreshToken = tokens.refresh_token;
    
    res.redirect('/dashboard');
  } catch (err) {
    res.status(500).json({ error: 'Token exchange failed' });
  }
});
 
// Protected route
app.get('/dashboard', async (req, res) => {
  if (!req.session.accessToken) {
    return res.redirect('/login');
  }
  
  try {
    const response = await fetch(`${AUTH_SERVER}/userinfo`, {
      headers: { Authorization: `Bearer ${req.session.accessToken}` },
    });
    
    if (response.status === 401) {
      // Token expired, try refresh
      const refreshed = await refreshToken(req.session.refreshToken);
      if (refreshed) {
        req.session.accessToken = refreshed.access_token;
        req.session.refreshToken = refreshed.refresh_token;
        return res.redirect('/dashboard');
      }
      return res.redirect('/login');
    }
    
    const user = await response.json();
    res.json({ message: `Welcome, ${user.name}`, user });
  } catch (err) {
    res.status(500).json({ error: 'Failed to fetch user info' });
  }
});
 
async function refreshToken(refreshToken: string) {
  const response = await fetch(`${AUTH_SERVER}/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: CLIENT_ID,
      client_secret: CLIENT_SECRET,
    }),
  });
  
  if (!response.ok) return null;
  return response.json();
}

OAuth implementation

Real-World Use Cases

Use Case 1: Single Sign-On for Web Application

A web application needs to authenticate users through a centralized identity provider. The Authorization Code flow with PKCE is the correct choice, even for server-side applications, because PKCE protects against authorization code interception attacks.

Use Case 2: Microservice-to-Microservice Communication

When one microservice needs to call another microservice API, the Client Credentials grant is appropriate. There is no user involved, and the service authenticates with its own credentials.

// Service-to-service authentication
class ServiceClient {
  private token: string | null = null;
  private tokenExpiry: number = 0;
  
  async getToken(): Promise<string> {
    if (this.token && Date.now() < this.tokenExpiry) {
      return this.token;
    }
    
    const response = await fetch(`${AUTH_SERVER}/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: 'service:read service:write',
      }),
    });
    
    const data = await response.json();
    this.token = data.access_token;
    this.tokenExpiry = Date.now() + (data.expires_in * 1000) - 60000;
    return this.token;
  }
  
  async callService(url: string, options: RequestInit = {}) {
    const token = await this.getToken();
    return fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        Authorization: `Bearer ${token}`,
      },
    });
  }
}

Use Case 3: Mobile Application

Mobile applications should use the Authorization Code flow with PKCE. The Implicit flow is deprecated because tokens in URLs can be intercepted. PKCE eliminates the need for a client secret, which cannot be securely stored in a mobile app.

Use Case 4: CLI Tool

A CLI tool that needs to access a user account uses the Device Authorization flow. The tool displays a URL and code, the user authorizes on their browser, and the tool polls for the token.

Best Practices for Production

  1. Always use PKCE: Even for server-side applications, PKCE protects against authorization code interception. There is no reason not to use it.

  2. Never store tokens in localStorage: localStorage is vulnerable to XSS attacks. Store tokens in HTTP-only, secure, SameSite cookies or in server-side sessions.

  3. Validate the state parameter: The state parameter prevents CSRF attacks. Generate a random state value, store it in the session, and validate it when the callback arrives.

  4. Use short-lived access tokens: Set access token expiry to 15-60 minutes. Use refresh tokens for long-lived sessions.

  5. Implement token refresh: Handle token expiry gracefully by refreshing tokens before they expire or on the first 401 response.

  6. Validate JWT tokens: When receiving JWT tokens, validate the signature, issuer, audience, and expiry. Use a well-tested library like jose or jsonwebtoken.

  7. Rotate refresh tokens: When a refresh token is used, issue a new refresh token and invalidate the old one. This limits the window of token theft.

  8. Implement token revocation: Provide an endpoint to revoke tokens when users log out or change their password.

Common Pitfalls and Solutions

PitfallImpactSolution
Not using PKCEAuthorization code interceptionAlways use code_challenge and code_verifier
Storing tokens in localStorageXSS-based token theftUse HTTP-only cookies or server-side sessions
Not validating state parameterCSRF attacksGenerate random state, validate on callback
Long-lived access tokensStolen tokens grant extended accessUse short expiry with refresh tokens
Exposing client secret in frontendClient impersonationUse PKCE for public clients, keep secrets server-side
Not validating JWT signatureForged tokens acceptedValidate signature, issuer, audience, and expiry
Reusing refresh tokens indefinitelyStolen refresh token grants permanent accessRotate refresh tokens on each use

Performance Optimization

Token Caching

Cache access tokens for machine-to-machine flows to avoid requesting a new token for every API call.

class TokenCache {
  private cache = new Map<string, { token: string; expiry: number }>();
  
  async getToken(key: string, fetcher: () => Promise<TokenResponse>): Promise<string> {
    const cached = this.cache.get(key);
    if (cached && Date.now() < cached.expiry) {
      return cached.token;
    }
    
    const response = await fetcher();
    this.cache.set(key, {
      token: response.access_token,
      expiry: Date.now() + (response.expires_in * 1000) - 60000,
    });
    
    return response.access_token;
  }
}

JWT Validation Caching

Cache the JWKS (JSON Web Key Set) from the authorization server to avoid fetching it for every token validation.

import { createRemoteJWKSet, jwtVerify } from 'jose';
 
// Cache JWKS with automatic refresh
const JWKS = createRemoteJWKSet(
  new URL('https://auth.example.com/.well-known/jwks.json'),
  { cooldownDuration: 600000 } // 10 minutes
);
 
async function verifyToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com',
  });
  return payload;
}

Testing Strategies

import { generateKeyPair, SignJWT } from 'jose';
 
async function createTestToken(claims: Record<string, any> = {}) {
  const { privateKey } = await generateKeyPair('RS256');
  
  return new SignJWT({
    sub: 'user-123',
    iss: 'https://auth.example.com',
    aud: 'https://api.example.com',
    exp: Math.floor(Date.now() / 1000) + 3600,
    ...claims,
  })
    .setProtectedHeader({ alg: 'RS256' })
    .sign(privateKey);
}
 
// Test protected endpoint
test('returns 401 without token', async () => {
  const res = await request(app).get('/api/protected');
  expect(res.status).toBe(401);
});
 
test('returns 200 with valid token', async () => {
  const token = await createTestToken();
  const res = await request(app)
    .get('/api/protected')
    .set('Authorization', `Bearer ${token}`);
  expect(res.status).toBe(200);
});

Future Outlook

OAuth 2.1 consolidates the best practices from OAuth 2.0 into a single specification. PKCE becomes mandatory for all clients, the Implicit grant is removed, and refresh token rotation is recommended. These changes simplify the protocol and improve security by default.

DPoP (Demonstrating Proof-of-Possession) is an emerging standard that binds access tokens to the client that requested them, preventing token theft and replay attacks. This addresses one of the remaining security gaps in OAuth 2.0 where stolen bearer tokens can be used by anyone.

OAuth 2.0 Security Best Practices

Always use PKCE (Proof Key for Code Exchange) with the authorization code flow, even for server-side applications. PKCE prevents authorization code interception attacks by requiring the client to prove it initiated the authorization request. Use short-lived access tokens with refresh token rotation to limit the window of exposure if a token is compromised. Validate the state parameter on every authorization callback to prevent CSRF attacks. Store tokens securely using HTTP-only cookies or secure storage mechanisms, never in localStorage where they are accessible to XSS attacks. Rotate your client secrets periodically and monitor your OAuth provider's audit logs for suspicious activity.

Conclusion

OAuth 2.0 is the foundation of modern API security, but implementing it correctly requires understanding the nuances of each grant type and the security implications of every design decision.

Key takeaways:

  1. Use the Authorization Code flow with PKCE for all user-facing applications
  2. Use the Client Credentials flow for machine-to-machine communication
  3. Never use the Implicit grant for new applications
  4. Always validate the state parameter to prevent CSRF attacks
  5. Store tokens securely: HTTP-only cookies for web apps, secure storage for mobile apps
  6. Use short-lived access tokens with refresh tokens for long sessions
  7. Validate JWT signatures, issuer, audience, and expiry on every request
  8. Implement token revocation and refresh token rotation for maximum security

Start with the Authorization Code flow with PKCE as your default choice. It covers the majority of use cases and provides the strongest security guarantees. Only deviate from it when your specific requirements demand a different grant type.