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, PKCE, and Client Credentials

Understand OAuth 2.0 flows: when to use each, security considerations, and implementation.

OAuthSecurityAuthenticationAuthorization

By MinhVo

Introduction

OAuth 2.0 is the industry-standard protocol for authorization that has fundamentally changed how applications access user data. Since its ratification as RFC 6749 in 2012, OAuth 2.0 has become the backbone of modern authentication and authorization, powering everything from "Sign in with Google" buttons to enterprise API security. Yet despite its ubiquity, OAuth 2.0 remains one of the most misunderstood protocols in software development—developers often confuse it with authentication, choose the wrong flow for their use case, or implement it with security vulnerabilities that expose user data.

The protocol defines several authorization flows, each designed for specific application architectures and security requirements. The three most important flows—Authorization Code, Authorization Code with PKCE, and Client Credentials—cover the vast majority of real-world scenarios. Understanding when to use each flow, how they differ in their security guarantees, and how to implement them correctly is essential knowledge for any developer building applications that interact with APIs or handle user data.

This guide provides a deep dive into these three OAuth 2.0 flows, examining their architectures, security considerations, and practical implementations with real code examples. We'll also explore common vulnerabilities, best practices, and the evolving landscape of OAuth security recommendations.

OAuth security illustration

Understanding OAuth 2.0: Core Concepts and Terminology

The Four Roles

OAuth 2.0 defines four distinct roles that interact during the authorization process:

  • Resource Owner: The user who owns the protected data and can grant access to it. When you click "Allow" on a Google permissions screen, you're acting as the Resource Owner.
  • Client: The application requesting access to protected resources. This could be a web app, mobile app, or server-side service. The client is identified by its client_id and optionally authenticated with a client_secret.
  • Authorization Server: The server that authenticates the Resource Owner and issues access tokens after successful authorization. Examples include Auth0, Okta, Google's OAuth server, and AWS Cognito.
  • Resource Server: The API server that hosts the protected resources and validates access tokens to authorize requests. This is typically your backend API or a third-party API like the GitHub API.

Tokens Explained

OAuth 2.0 uses several types of tokens, each serving a specific purpose:

  • Access Token: A credential used to access protected resources. It's typically a short-lived JWT (JSON Web Token) or opaque string that contains claims about the user and the granted permissions (scopes).
  • Refresh Token: A long-lived credential used to obtain new access tokens without requiring the user to re-authorize. Refresh tokens are stored securely on the server and should never be exposed to the browser.
  • Authorization Code: A temporary, one-time-use code exchanged for an access token. It exists only in the Authorization Code flow and is used to prevent tokens from being exposed in browser URLs.
// JWT Access Token structure
interface AccessToken {
  iss: string;      // Issuer (authorization server URL)
  sub: string;      // Subject (user ID)
  aud: string;      // Audience (intended recipient)
  exp: number;      // Expiration time (Unix timestamp)
  iat: number;      // Issued at time
  scope: string;    // Granted permissions (space-separated)
  email?: string;   // Optional user claims
  roles?: string[]; // Optional role claims
}

Scopes: Controlling Access Granularity

Scopes define what the client can access. They're space-separated strings included in the authorization request, and the authorization server presents them to the user for consent:

// Requesting specific scopes
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('client_id', 'your-client-id');
authUrl.searchParams.set('redirect_uri', 'https://yourapp.com/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid profile email read:posts write:posts');
authUrl.searchParams.set('state', generateRandomState());

The openid scope triggers OpenID Connect (OIDC), which adds an ID token to the response—this is how OAuth 2.0 is extended to support authentication.

OAuth flow diagram

Flow 1: Authorization Code Grant

How It Works

The Authorization Code flow is the most secure OAuth 2.0 grant type for server-side applications. It works by separating the user-facing authorization step from the token exchange, ensuring that access tokens are never exposed in the browser's URL.

The flow follows these steps:

  1. The client redirects the user to the authorization server with a request containing client_id, redirect_uri, scope, and a state parameter for CSRF protection
  2. The authorization server authenticates the user (if not already logged in) and presents a consent screen
  3. Upon approval, the authorization server redirects back to the client's redirect_uri with an authorization code and the original state parameter
  4. The client's backend exchanges the authorization code for tokens by making a POST request to the authorization server's token endpoint, including the client_secret for server-side authentication
  5. The authorization server validates the code, verifies the client credentials, and returns access and refresh tokens
  6. The client uses the access token to make API requests to the resource server

Implementation

// Step 1: Generate authorization URL (server-side)
import crypto from 'crypto';
 
interface AuthConfig {
  clientId: string;
  clientSecret: string;
  authorizationEndpoint: string;
  tokenEndpoint: string;
  redirectUri: string;
  scope: string;
}
 
function generateAuthorizationUrl(config: AuthConfig): { url: string; state: string } {
  const state = crypto.randomBytes(32).toString('hex');
 
  const params = new URLSearchParams({
    response_type: 'code',
    client_id: config.clientId,
    redirect_uri: config.redirectUri,
    scope: config.scope,
    state,
  });
 
  return {
    url: `${config.authorizationEndpoint}?${params.toString()}`,
    state,
  };
}
// Step 2: Handle callback and exchange code for tokens
import express from 'express';
 
const app = express();
 
app.get('/callback', async (req, res) => {
  const { code, state } = req.query;
 
  // Verify state parameter to prevent CSRF
  if (state !== req.session.expectedState) {
    return res.status(403).json({ error: 'Invalid state parameter' });
  }
 
  if (!code || typeof code !== 'string') {
    return res.status(400).json({ error: 'Authorization code missing' });
  }
 
  try {
    // Exchange authorization code for tokens
    const tokenResponse = await fetch(config.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': `Basic ${Buffer.from(
          `${config.clientId}:${config.clientSecret}`
        ).toString('base64')}`,
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: config.redirectUri,
      }),
    });
 
    if (!tokenResponse.ok) {
      const error = await tokenResponse.json();
      throw new Error(`Token exchange failed: ${error.error_description}`);
    }
 
    const tokens = await tokenResponse.json();
    // tokens.access_token, tokens.refresh_token, tokens.expires_in
 
    // Store tokens securely (server-side session, encrypted cookie, etc.)
    req.session.accessToken = tokens.access_token;
    req.session.refreshToken = tokens.refresh_token;
 
    res.redirect('/dashboard');
  } catch (error) {
    console.error('OAuth callback error:', error);
    res.status(500).json({ error: 'Authentication failed' });
  }
});
// Step 3: Use access token for API requests
async function fetchProtectedResource(accessToken: string, resourceUrl: string) {
  const response = await fetch(resourceUrl, {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json',
    },
  });
 
  if (response.status === 401) {
    // Token expired - attempt refresh
    throw new TokenExpiredError('Access token expired, refresh required');
  }
 
  if (!response.ok) {
    throw new Error(`API request failed: ${response.status}`);
  }
 
  return response.json();
}
// Step 4: Token refresh mechanism
async function refreshAccessToken(
  config: AuthConfig,
  refreshToken: string
): Promise<TokenResponse> {
  const response = await fetch(config.tokenEndpoint, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': `Basic ${Buffer.from(
        `${config.clientId}:${config.clientSecret}`
      ).toString('base64')}`,
    },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
    }),
  });
 
  if (!response.ok) {
    throw new Error('Token refresh failed - user re-authorization required');
  }
 
  return response.json();
}

When to Use Authorization Code Flow

The classic Authorization Code flow is appropriate when:

  • Your application has a secure server-side component that can store the client_secret
  • You need long-lived access via refresh tokens
  • You're building a traditional web application with server-side rendering
  • You need the highest level of security assurance

Flow 2: Authorization Code with PKCE

The Problem PKCE Solves

The classic Authorization Code flow assumes the client can securely store a client_secret. But what about public clients—applications that run entirely in the browser (Single Page Applications) or on mobile devices? These clients cannot keep secrets because their source code and network traffic are visible to users.

Before PKCE, these public clients were forced to use the Implicit Grant flow, which returned tokens directly in the URL fragment. This was insecure because:

  • Tokens appeared in browser history and server logs
  • Tokens could be intercepted by malicious JavaScript
  • There was no mechanism for token refresh

PKCE (Proof Key for Code Exchange, pronounced "pixy") solves this by adding a cryptographic challenge to the Authorization Code flow, eliminating the need for a client_secret while maintaining security.

How PKCE Works

The PKCE extension adds three parameters to the standard Authorization Code flow:

  1. Code Verifier: A cryptographically random string (43-128 characters) generated by the client
  2. Code Challenge: A transformed version of the code verifier (typically its SHA-256 hash, Base64url-encoded)
  3. Code Challenge Method: The transformation method used (S256 for SHA-256, or plain for no transformation)
// PKCE implementation
import crypto from 'crypto';
 
function generateCodeVerifier(): string {
  return crypto.randomBytes(32)
    .toString('base64url');  // Base64url encoding (no padding)
}
 
function generateCodeChallenge(verifier: string): string {
  return crypto.createHash('sha256')
    .update(verifier)
    .digest('base64url');
}
 
// Usage
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
// codeVerifier: "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// codeChallenge: "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"

Complete PKCE Flow Implementation

// Client-side PKCE flow (SPA)
class OAuthPKCEClient {
  private config: AuthConfig;
  private codeVerifier: string | null = null;
 
  constructor(config: AuthConfig) {
    this.config = config;
  }
 
  // Step 1: Start the authorization flow
  async startAuth(): Promise<string> {
    this.codeVerifier = generateCodeVerifier();
    const codeChallenge = generateCodeChallenge(this.codeVerifier);
 
    // Store code verifier in sessionStorage (cleared on tab close)
    sessionStorage.setItem('pkce_code_verifier', this.codeVerifier);
 
    const state = crypto.randomBytes(16).toString('hex');
    sessionStorage.setItem('oauth_state', state);
 
    const params = new URLSearchParams({
      response_type: 'code',
      client_id: this.config.clientId,
      redirect_uri: this.config.redirectUri,
      scope: this.config.scope,
      state,
      code_challenge: codeChallenge,
      code_challenge_method: 'S256',
    });
 
    return `${this.config.authorizationEndpoint}?${params.toString()}`;
  }
 
  // Step 2: Handle the callback
  async handleCallback(code: string, state: string): Promise<TokenResponse> {
    const expectedState = sessionStorage.getItem('oauth_state');
    const savedVerifier = sessionStorage.getItem('pkce_code_verifier');
 
    // Clean up stored values
    sessionStorage.removeItem('oauth_state');
    sessionStorage.removeItem('pkce_code_verifier');
 
    if (state !== expectedState) {
      throw new Error('Invalid state parameter - possible CSRF attack');
    }
 
    if (!savedVerifier) {
      throw new Error('Code verifier not found - session may have expired');
    }
 
    // Step 3: Exchange code for tokens WITH the code verifier
    const response = await fetch(this.config.tokenEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code,
        redirect_uri: this.config.redirectUri,
        client_id: this.config.clientId,
        code_verifier: savedVerifier,
      }),
    });
 
    if (!response.ok) {
      const error = await response.json();
      throw new Error(`Token exchange failed: ${error.error_description}`);
    }
 
    return response.json();
  }
}

The critical security property is that even if an attacker intercepts the authorization code, they cannot exchange it for tokens because they don't have the code verifier. The authorization server verifies that hash(code_verifier) === code_challenge during the token exchange.

When to Use PKCE

PKCE should be used for all public clients:

  • Single Page Applications (SPAs) running in the browser
  • Mobile applications (native iOS, Android, desktop)
  • CLI tools and desktop applications
  • Any client that cannot securely store a client_secret

Important: The OAuth 2.1 draft specification makes PKCE mandatory for all clients, not just public ones. This reflects the security community's recognition that PKCE provides defense-in-depth even for confidential clients.

Flow 3: Client Credentials Grant

How It Works

The Client Credentials flow is fundamentally different from the other two flows because it doesn't involve a user at all. It's used for machine-to-machine (M2M) communication where the client is also the resource owner—think backend services communicating with each other, cron jobs accessing APIs, or microservices fetching data from internal services.

The flow is remarkably simple:

  1. The client authenticates with the authorization server using its client_id and client_secret
  2. The authorization server validates the credentials and returns an access token
  3. The client uses the access token to access protected resources

There's no user interaction, no browser redirect, and no authorization code. The token represents the application itself, not a user.

Implementation

// Client Credentials flow implementation
class ClientCredentialsAuth {
  private config: {
    clientId: string;
    clientSecret: string;
    tokenEndpoint: string;
    scopes: string[];
  };
 
  private accessToken: string | null = null;
  private tokenExpiresAt: number = 0;
 
  constructor(config: typeof this.config) {
    this.config = config;
  }
 
  async getAccessToken(): Promise<string> {
    // Return cached token if still valid (with 30s buffer)
    if (this.accessToken && Date.now() < this.tokenExpiresAt - 30000) {
      return this.accessToken;
    }
 
    const response = await fetch(this.config.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Authorization': `Basic ${Buffer.from(
          `${this.config.clientId}:${this.config.clientSecret}`
        ).toString('base64')}`,
      },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        scope: this.config.scopes.join(' '),
      }),
    });
 
    if (!response.ok) {
      const error = await response.json();
      throw new Error(`Client credentials grant failed: ${error.error_description}`);
    }
 
    const data = await response.json();
    this.accessToken = data.access_token;
    this.tokenExpiresAt = Date.now() + data.expires_in * 1000;
 
    return this.accessToken;
  }
 
  async authenticatedFetch(url: string, options: RequestInit = {}): Promise<Response> {
    const token = await this.getAccessToken();
    return fetch(url, {
      ...options,
      headers: {
        ...options.headers,
        'Authorization': `Bearer ${token}`,
      },
    });
  }
}
 
// Usage: Microservice calling another microservice
const authService = new ClientCredentialsAuth({
  clientId: process.env.SERVICE_CLIENT_ID!,
  clientSecret: process.env.SERVICE_CLIENT_SECRET!,
  tokenEndpoint: 'https://auth.internal/oauth/token',
  scopes: ['service:read', 'service:write'],
});
 
// Make authenticated API calls
const users = await authService.authenticatedFetch(
  'https://users-api.internal/users',
  { method: 'GET' }
);

Service Account Patterns

In production, Client Credentials flow often involves service accounts with specific permission sets:

// Service account configuration
interface ServiceAccount {
  clientId: string;
  clientSecret: string;
  scopes: string[];
  description: string;
  allowedIps?: string[];  // Optional IP allowlisting
}
 
// Multiple service accounts for different purposes
const serviceAccounts: Record<string, ServiceAccount> = {
  'order-service': {
    clientId: process.env.ORDER_SERVICE_CLIENT_ID!,
    clientSecret: process.env.ORDER_SERVICE_CLIENT_SECRET!,
    scopes: ['orders:read', 'orders:write', 'inventory:read'],
    description: 'Order processing microservice',
  },
  'analytics-service': {
    clientId: process.env.ANALYTICS_CLIENT_ID!,
    clientSecret: process.env.ANALYTICS_CLIENT_SECRET!,
    scopes: ['analytics:read', 'users:read'],
    description: 'Analytics and reporting service',
  },
  'cron-scheduler': {
    clientId: process.env.CRON_CLIENT_ID!,
    clientSecret: process.env.CRON_CLIENT_SECRET!,
    scopes: ['jobs:execute', 'notifications:send'],
    description: 'Scheduled task runner',
  },
};

When to Use Client Credentials

The Client Credentials flow is appropriate when:

  • Your application needs to access its own resources (not user resources)
  • You're building server-to-server or microservice communication
  • A cron job or background worker needs API access
  • You're implementing a CLI tool that acts on behalf of an organization

Security Best Practices

Token Storage

How you store tokens is critical to the security of your OAuth implementation:

// Secure token storage patterns
 
// 1. Server-side: Encrypted session storage
import { IronSession } from 'iron-session';
 
declare module 'iron-session' {
  interface IronSessionData {
    accessToken?: string;
    refreshToken?: string;
    userId?: string;
  }
}
 
const sessionOptions = {
  password: process.env.SESSION_SECRET!, // 32+ character secret
  cookieName: 'app_session',
  cookieOptions: {
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    sameSite: 'lax' as const,
    maxAge: 60 * 60 * 24 * 7, // 7 days
  },
};
 
// 2. SPA: Memory-only storage (most secure for public clients)
class SecureTokenStore {
  private accessToken: string | null = null;
 
  setToken(token: string): void {
    this.accessToken = token;
  }
 
  getToken(): string | null {
    return this.accessToken;
  }
 
  clearToken(): void {
    this.accessToken = null;
  }
}
// Token is lost on page refresh, requiring silent re-authentication
 
// 3. Mobile: Use platform secure storage
// iOS: Keychain Services
// Android: EncryptedSharedPreferences
// React Native: react-native-keychain

Common Vulnerabilities and Mitigations

VulnerabilityDescriptionMitigation
CSRF on callbackAttacker initiates OAuth flow with victim's sessionAlways validate state parameter; use cryptographic random values
Authorization code interceptionAttacker intercepts code from redirectUse PKCE (mandatory in OAuth 2.1); use exact redirect URI matching
Token leakage via referrerAccess token in URL leaks via Referer headerUse Authorization Code flow (tokens in body, not URL); set Referrer-Policy
Open redirectManipulated redirect_uri redirects to attacker's siteExact match redirect URIs (no wildcards); never allow localhost in production
Token theft via XSSJavaScript accesses stored tokensUse HttpOnly cookies for web; memory-only storage for SPAs; strong CSP headers
Refresh token theftStolen refresh token grants persistent accessImplement refresh token rotation; bind tokens to client fingerprint

Redirect URI Validation

// Strict redirect URI validation on the authorization server
function validateRedirectUri(
  registeredUris: string[],
  requestedUri: string
): boolean {
  // Exact match only - no wildcard, no pattern matching
  return registeredUris.some((uri) => uri === requestedUri);
}
 
// Bad: Wildcard matching
// *.example.com could match attacker.example.com
 
// Good: Exact matching
// https://app.example.com/callback
// https://app.example.com/auth/callback

Comparison of OAuth 2.0 Flows

FeatureAuthorization CodeAuthorization Code + PKCEClient Credentials
Client TypeConfidentialPublicConfidential
User InvolvementYes (interactive)Yes (interactive)No (machine-to-machine)
Client Secret RequiredYesNoYes
PKCE RequiredNo (recommended)Yes (mandatory)N/A
Token LocationServer-sideClient-side (memory)Server-side
Refresh TokensYesYes (with caveats)Not applicable
Use CaseServer web appsSPAs, mobile appsM2M, microservices
Security LevelHighHighHigh (for trusted clients)

Advanced Patterns and Techniques

Token Introspection

When your resource server needs to validate an opaque access token, it can use token introspection:

// Token introspection endpoint
async function introspectToken(token: string): Promise<TokenInfo> {
  const response = await fetch('https://auth.example.com/oauth/introspect', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/x-www-form-urlencoded',
      'Authorization': `Basic ${Buffer.from(
        `${INTROSPECTION_CLIENT_ID}:${INTROSPECTION_CLIENT_SECRET}`
      ).toString('base64')}`,
    },
    body: new URLSearchParams({ token }),
  });
 
  return response.json();
  // Returns: { active: true, sub: "user123", scope: "read write", exp: 1234567890 }
}

JWT Validation (Alternative to Introspection)

For JWT access tokens, the resource server can validate tokens locally without calling the authorization server:

import jwt from 'jsonwebtoken';
import jwksClient from 'jwks-rsa';
 
const client = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json',
  cache: true,
  rateLimit: true,
});
 
async function verifyAccessToken(token: string): Promise<jwt.JwtPayload> {
  const decoded = jwt.decode(token, { complete: true });
  if (!decoded) throw new Error('Invalid token format');
 
  const key = await client.getSigningKey(decoded.header.kid);
  const signingKey = key.getPublicKey();
 
  return jwt.verify(token, signingKey, {
    algorithms: ['RS256'],
    issuer: 'https://auth.example.com',
    audience: 'https://api.example.com',
  }) as jwt.JwtPayload;
}

Testing Strategies

// OAuth integration tests
import nock from 'nock';
 
describe('OAuth Flows', () => {
  describe('Authorization Code + PKCE', () => {
    it('should generate valid PKCE parameters', () => {
      const verifier = generateCodeVerifier();
      const challenge = generateCodeChallenge(verifier);
 
      expect(verifier).toHaveLength(43); // Base64url of 32 bytes
      expect(challenge).toHaveLength(43);
      expect(verifier).not.toBe(challenge);
    });
 
    it('should reject mismatched state parameter', async () => {
      const client = new OAuthPKCEClient(mockConfig);
      await expect(
        client.handleCallback('valid-code', 'wrong-state')
      ).rejects.toThrow('Invalid state parameter');
    });
 
    it('should exchange code with correct verifier', async () => {
      nock('https://auth.example.com')
        .post('/oauth/token')
        .reply(200, {
          access_token: 'test-access-token',
          token_type: 'Bearer',
          expires_in: 3600,
        });
 
      const client = new OAuthPKCEClient(mockConfig);
      const tokens = await client.handleCallback('test-code', 'expected-state');
 
      expect(tokens.access_token).toBe('test-access-token');
    });
  });
 
  describe('Client Credentials', () => {
    it('should cache tokens until expiry', async () => {
      nock('https://auth.example.com')
        .post('/oauth/token')
        .once()
        .reply(200, { access_token: 'token-1', expires_in: 3600 });
 
      const auth = new ClientCredentialsAuth(mockConfig);
      const token1 = await auth.getAccessToken();
      const token2 = await auth.getAccessToken();
 
      expect(token1).toBe('token-2');
      expect(token2).toBe('token-1');
    });
  });
});

Future Outlook

The OAuth ecosystem continues to evolve with several important developments:

  • OAuth 2.1: Consolidates best practices from RFC 6749, RFC 6819, and various extensions. PKCE becomes mandatory for all clients. The Implicit and Resource Owner Password grants are removed entirely.
  • DPoP (Demonstrating Proof-of-Possession): Adds sender-constrained access tokens that are cryptographically bound to the client, preventing token theft and replay attacks.
  • RAR (Rich Authorization Requests): Allows clients to request specific permissions using structured JSON objects instead of simple scope strings, enabling more fine-grained authorization.
  • Grant Management API: Provides a standardized way for users to view and revoke granted permissions across applications.

Conclusion

Understanding OAuth 2.0 flows is fundamental to building secure applications that interact with APIs and handle user data. The key takeaways are:

  1. Use Authorization Code + PKCE for all interactive clients: Whether you're building a server-side web app, SPA, or mobile app, PKCE provides defense-in-depth and is mandatory in OAuth 2.1. There's no reason to use the Implicit flow in modern applications.

  2. Use Client Credentials for machine-to-machine communication: When no user is involved, this simple flow provides secure service-to-service authentication without unnecessary complexity.

  3. Never store tokens in localStorage: Use HttpOnly cookies for web applications, secure platform storage for mobile apps, and in-memory storage for SPAs where page refresh re-authentication is acceptable.

  4. Always validate state and redirect URIs: These two checks prevent the most common OAuth vulnerabilities—CSRF and open redirect attacks.

  5. Implement proper token lifecycle management: Use refresh tokens for long-lived sessions, implement token rotation, and handle token expiry gracefully in your application.

  6. Stay current with OAuth 2.1 and emerging standards: The protocol continues to evolve, and keeping your implementation up to date with the latest security recommendations is essential for protecting your users.

OAuth 2.0 is a powerful but nuanced protocol. Taking the time to understand each flow's security guarantees and implementing them correctly will save you from the security incidents that have affected even the largest technology companies.