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.1: What's New and Migration Guide

Complete guide to OAuth 2.1: PKCE required for all clients, implicit flow eliminated, stricter redirect URI matching, refresh token rotation, and step-by-step migration from OAuth 2.0 with TypeScript code examples.

OAuthSecurityAuthenticationStandardsIdentity

By MinhVo

OAuth 2.1 consolidates a decade of security best practices, RFCs, and real-world lessons into a single, streamlined specification. Rather than introducing radical new concepts, it codifies what the security community has been recommending since 2012: require PKCE for all flows, eliminate the implicit grant, enforce stricter redirect URI matching, and mandate refresh token rotation. If your application already follows current best practices, migration is straightforward. If it doesn't, this guide will help you identify and close the gaps.

Understanding OAuth 2.1 is essential for any developer building applications that authenticate users or access protected resources. The specification removes ambiguity, eliminates insecure legacy flows, and establishes a clear baseline for secure authorization.

OAuth security flow

What Changed in OAuth 2.1

OAuth 2.1 is a consolidation, not a revolution. It merges RFC 6749 (the original OAuth 2.0), RFC 6750 (Bearer tokens), RFC 7636 (PKCE), RFC 8252 (native apps), and several security best practices into a single specification. Here are the key changes:

1. PKCE Is Required for All Clients

Previously, Proof Key for Code Exchange (PKCE) was recommended only for public clients (SPAs, mobile apps, CLI tools). OAuth 2.1 requires PKCE for every authorization code request, including confidential clients (server-side web applications with a client secret).

Why this matters: PKCE defends against authorization code interception attacks. Even if an attacker intercepts the authorization code during the redirect, they cannot exchange it for tokens without the code verifier that only the legitimate client possesses. Requiring PKCE for all clients closes a gap where confidential clients could still be vulnerable to code interception in certain network configurations.

What changes: Every /authorize request must include code_challenge and code_challenge_method parameters. Every /token request must include code_verifier.

2. The Implicit Grant Is Removed

The implicit grant, which returned tokens directly in the URL fragment (#access_token=...), is completely removed from OAuth 2.1. It was always the least secure flow:

  • Tokens in the URL fragment are visible in browser history
  • Tokens can leak through the Referer header
  • Open redirectors can capture tokens from the fragment
  • There's no mechanism for token rotation or refresh

What replaces it: The authorization code flow with PKCE works for every client type — SPAs, mobile apps, native apps, and server-side applications. There is no scenario where the implicit grant was necessary.

3. The Resource Owner Password Credentials Grant Is Removed

The ROPC grant, which asked users to enter their username and password directly into the client application, is eliminated. This flow was fundamentally flawed:

  • It violates the principle that clients should never see user credentials
  • It's incompatible with multi-factor authentication (MFA)
  • It doesn't support federated identity (social login, SAML, OIDC)
  • It encourages credential sharing between applications

What replaces it: Use the authorization code flow for interactive login, or client credentials for machine-to-machine communication.

4. Redirect URI Exact Matching Is Required

Authorization servers must perform exact string matching on redirect URIs. No more wildcard subdomains, no more partial path matching, no more query parameter tolerance.

Why this matters: Permissive redirect URI matching enables open redirect attacks. An attacker registers https://evil.example.com/auth/callback and if the authorization server accepts https://*.example.com/*, the attacker captures authorization codes.

What changes: Every redirect URI registered with the authorization server must be an exact string. https://app.example.com/callback does not match https://app.example.com/callback/ or https://app.example.com/callback?next=home.

5. Refresh Token Rotation and Expiration

Refresh tokens must either be one-time-use (rotation) or have a defined maximum lifetime. Bearer refresh tokens must include sender-constraining or be rotated.

Why this matters: A stolen refresh token grants indefinite access if it never expires and never changes. Rotation ensures that each use of a refresh token invalidates it and issues a new one. If an attacker uses a stolen refresh token, the legitimate client's next refresh attempt fails, alerting the system to the compromise.

6. Bearer Tokens in URI Queries Are Prohibited

Access tokens must be sent in the Authorization header or in the request body for POST requests. Passing tokens as query parameters (?access_token=...) is prohibited because query strings are logged by servers, proxies, CDNs, and browsers.

// CORRECT: Token in Authorization header
const response = await fetch("https://api.example.com/data", {
  headers: {
    Authorization: `Bearer ${accessToken}`,
  },
});
 
// PROHIBITED: Token in query string
const response = await fetch(
  `https://api.example.com/data?access_token=${accessToken}`
);

Migration Checklist

Before starting the technical migration, audit your current OAuth implementation against each requirement:

interface OAuthMigrationChecklist {
  // Authorization Code Flow
  pkceRequired: boolean;          // Must be true for ALL clients
  implicitGrantRemoved: boolean;  // Remove any /authorize?response_type=token endpoints
  ropcGrantRemoved: boolean;      // Remove any /token grant_type=password endpoints
 
  // Redirect URIs
  exactRedirectMatching: boolean; // No wildcards, no prefix matching
 
  // Token Handling
  refreshRotation: boolean;       // Rotate refresh tokens on each use
  bearerInHeader: boolean;        // No access_token in query strings
  tokenExpiration: boolean;       // All tokens have explicit expiry
 
  // Client Authentication
  clientSecretsSecure: boolean;   // Stored securely, never in frontend code
  mtlsOrDpop: boolean;            // Sender-constraining for production (recommended)
}

Walk through each item in your codebase. The sections below cover the most common migration paths.

Migrating from Implicit to Authorization Code + PKCE

This is the most common and impactful migration. If your SPA currently uses the implicit flow, you need to switch to the authorization code flow with PKCE.

Before: Implicit Flow (Remove This)

// DEPRECATED: Implicit flow — DO NOT USE
const authUrl = `${issuer}/authorize?` +
  `response_type=token&` +
  `client_id=${clientId}&` +
  `redirect_uri=${redirectUri}&` +
  `scope=${scopes}`;
 
// After redirect, parse token from URL fragment
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
const accessToken = params.get("access_token");

After: Authorization Code Flow with PKCE

// Generate PKCE values
function generateCodeVerifier(): string {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}
 
async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const digest = await crypto.subtle.digest("SHA-256", data);
  return base64UrlEncode(new Uint8Array(digest));
}
 
function base64UrlEncode(buffer: Uint8Array): string {
  return btoa(String.fromCharCode(...buffer))
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}
 
// Step 1: Build authorization URL with PKCE
async function buildAuthUrl(): Promise<string> {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
 
  // Store verifier for later use in token exchange
  sessionStorage.setItem("pkce_code_verifier", codeVerifier);
 
  const state = crypto.randomUUID();
  sessionStorage.setItem("oauth_state", state);
 
  return `${issuer}/authorize?` +
    `response_type=code&` +
    `client_id=${encodeURIComponent(clientId)}&` +
    `redirect_uri=${encodeURIComponent(redirectUri)}&` +
    `scope=${encodeURIComponent(scopes)}&` +
    `code_challenge=${codeChallenge}&` +
    `code_challenge_method=S256&` +
    `state=${state}`;
}
 
// Step 2: Exchange authorization code for tokens
async function exchangeCode(code: string): Promise<TokenResponse> {
  const codeVerifier = sessionStorage.getItem("pkce_code_verifier");
  if (!codeVerifier) throw new Error("PKCE code verifier not found");
 
  const response = await fetch(`${issuer}/token`, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: redirectUri,
      client_id: clientId,
      code_verifier: codeVerifier,
    }),
  });
 
  if (!response.ok) {
    throw new Error(`Token exchange failed: ${response.status}`);
  }
 
  const tokens = await response.json();
 
  // Clean up PKCE values
  sessionStorage.removeItem("pkce_code_verifier");
  sessionStorage.removeItem("oauth_state");
 
  return tokens;
}

Handling the Callback

async function handleOAuthCallback(): Promise<void> {
  const params = new URLSearchParams(window.location.search);
  const code = params.get("code");
  const state = params.get("state");
  const error = params.get("error");
 
  if (error) {
    throw new Error(`OAuth error: ${error} — ${params.get("error_description")}`);
  }
 
  if (!code) throw new Error("No authorization code in callback");
 
  // Verify state to prevent CSRF
  const savedState = sessionStorage.getItem("oauth_state");
  if (state !== savedState) {
    throw new Error("State mismatch — possible CSRF attack");
  }
 
  const tokens = await exchangeCode(code);
  localStorage.setItem("access_token", tokens.access_token);
  localStorage.setItem("refresh_token", tokens.refresh_token);
  localStorage.setItem("token_expiry", String(Date.now() + tokens.expires_in * 1000));
 
  // Clean URL
  window.history.replaceState({}, "", window.location.pathname);
}

Implementing Refresh Token Rotation

OAuth 2.1 requires refresh tokens to be rotated or have a maximum lifetime:

class TokenManager {
  private accessToken: string | null = null;
  private refreshToken: string | null = null;
  private tokenExpiry: number = 0;
  private refreshPromise: Promise<void> | null = null;
 
  constructor(private issuer: string, private clientId: string) {
    this.loadTokens();
  }
 
  private loadTokens(): void {
    this.accessToken = localStorage.getItem("access_token");
    this.refreshToken = localStorage.getItem("refresh_token");
    this.tokenExpiry = Number(localStorage.getItem("token_expiry") || 0);
  }
 
  private saveTokens(tokens: TokenResponse): void {
    this.accessToken = tokens.access_token;
    this.refreshToken = tokens.refresh_token;
    this.tokenExpiry = Date.now() + tokens.expires_in * 1000;
 
    localStorage.setItem("access_token", tokens.access_token);
    localStorage.setItem("refresh_token", tokens.refresh_token);
    localStorage.setItem("token_expiry", String(this.tokenExpiry));
  }
 
  async getAccessToken(): Promise<string> {
    // Return if token is still valid (with 30s buffer)
    if (this.accessToken && Date.now() < this.tokenExpiry - 30000) {
      return this.accessToken;
    }
 
    // Prevent concurrent refresh attempts
    if (!this.refreshPromise) {
      this.refreshPromise = this.refreshTokens();
    }
 
    await this.refreshPromise;
    this.refreshPromise = null;
 
    if (!this.accessToken) {
      throw new Error("Not authenticated — please log in");
    }
 
    return this.accessToken;
  }
 
  private async refreshTokens(): Promise<void> {
    if (!this.refreshToken) {
      this.clearTokens();
      return;
    }
 
    const response = await fetch(`${this.issuer}/token`, {
      method: "POST",
      headers: { "Content-Type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: this.refreshToken,
        client_id: this.clientId,
      }),
    });
 
    if (!response.ok) {
      this.clearTokens();
      window.location.href = "/login";
      return;
    }
 
    const tokens = await response.json();
    this.saveTokens(tokens);
  }
 
  private clearTokens(): void {
    this.accessToken = null;
    this.refreshToken = null;
    this.tokenExpiry = 0;
    localStorage.removeItem("access_token");
    localStorage.removeItem("refresh_token");
    localStorage.removeItem("token_expiry");
  }
 
  isAuthenticated(): boolean {
    return !!this.refreshToken;
  }
 
  logout(): void {
    this.clearTokens();
    window.location.href = "/login";
  }
}

Strict Redirect URI Matching

Audit your authorization server configuration for any non-exact redirect URI matches:

# BEFORE: Permissive matching (INSECURE — remove these patterns)
allowed_redirect_uris:
  - "https://*.example.com/*"       # Wildcard subdomain
  - "https://app.example.com"       # No trailing slash tolerance
  - "https://app.example.com/*"     # Wildcard path
 
# AFTER: Exact matching (OAuth 2.1 required)
allowed_redirect_uris:
  - "https://app.example.com/callback"
  - "https://app.example.com/auth/callback"
  - "http://localhost:3000/callback"    # Development only
  - "http://localhost:3000/auth/callback"

Every redirect URI used in your application must be registered exactly as it appears in the authorization request. This includes trailing slashes, port numbers, and query parameters.

While not strictly required by OAuth 2.1, Demonstration of Proof-of-Possession (DPoP) is recommended as a sender-constraining mechanism. It binds access tokens to a specific client key pair:

// Generate DPoP key pair
async function generateDPoPKeyPair(): Promise<CryptoKeyPair> {
  return crypto.subtle.generateKey(
    { name: "ECDSA", namedCurve: "P-256" },
    true,
    ["sign", "verify"]
  );
}
 
// Create DPoP proof for token request
async function createDPoPProof(
  keyPair: CryptoKeyPair,
  method: string,
  url: string,
  nonce?: string
): Promise<string> {
  const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey);
 
  const header = {
    typ: "dpop+jwt",
    alg: "ES256",
    jwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y },
  };
 
  const payload = {
    jti: crypto.randomUUID(),
    htm: method.toUpperCase(),
    htu: url,
    iat: Math.floor(Date.now() / 1000),
    ...(nonce ? { nonce } : {}),
  };
 
  // Sign with private key (implementation depends on JWT library)
  return signJWT(header, payload, keyPair.privateKey);
}

Common Pitfalls

PitfallImpactSolution
Keeping implicit flow endpointsToken leakage via URL fragmentsRemove all response_type=token support from authorization server
Not rotating refresh tokensStolen refresh token grants indefinite accessImplement rotation or set maximum lifetime
Wildcard redirect URIsOpen redirect code interceptionUse exact string matching for all redirect URIs
Tokens in query stringsLogged by proxies, CDNs, and browsersUse Authorization header exclusively
PKCE only for public clientsInterception attacks on confidential clientsRequire PKCE for every authorization code request
No state parameterCSRF attacks on OAuth callbackAlways include and verify the state parameter
Storing tokens in localStorageXSS can steal tokensUse httpOnly cookies or in-memory storage with refresh rotation
Excessive scopesOver-permissioned tokensRequest only the scopes needed for the current operation

Best Practices

  1. Migrate incrementally — start by adding PKCE to your existing authorization code flow, then remove implicit flow endpoints
  2. Implement refresh token rotation — each refresh invalidates the old token and issues a new one; detect token reuse as a security event
  3. Use exact redirect URI matching — register every redirect URI explicitly; don't rely on pattern matching
  4. Audit scopes regularly — review which scopes your application requests and which scopes each service account uses
  5. Implement DPoP or mTLS — sender-constraining prevents stolen tokens from being used by other clients
  6. Monitor for anomalous token usage — alert on refresh token reuse, token requests from unexpected IPs, and unusual scope requests
  7. Use short-lived access tokens — 5-15 minutes is typical; rely on refresh tokens for long sessions

Security Incident Response Planning

Even with robust security measures in place, having a well-defined incident response plan is essential. Security incidents are not a matter of if but when, and the speed and effectiveness of your response can significantly limit the damage.

Incident Classification Framework

Classify security incidents by severity to ensure appropriate response allocation:

  • Critical (P0): Active data breach, unauthorized access to production systems, ransomware attack. Response time: immediate, within 15 minutes.
  • High (P1): Attempted breach detected, vulnerability actively exploited in the wild, compromised credentials. Response time: within 1 hour.
  • Medium (P2): Suspicious activity detected, non-critical vulnerability discovered, phishing attempt targeting employees. Response time: within 4 hours.
  • Low (P3): Security policy violation, minor configuration drift, failed login attempts exceeding threshold. Response time: within 24 hours.

Automated Security Scanning in CI/CD

Integrate security scanning at multiple stages of your deployment pipeline:

# .github/workflows/security.yml
name: Security Scan
on: [push, pull_request]
 
jobs:
  dependency-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run npm audit
        run: npm audit --audit-level=high
      - name: Run Snyk security scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
 
  sast:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Run Semgrep SAST
        uses: semgrep/semgrep-action@v1
        with:
          config: >-
            p/owasp-top-ten
            p/security-audit
            p/javascript
 
  container-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Build Docker image
        run: docker build -t app:scan .
      - name: Run Trivy container scan
        uses: aquasecurity/trivy-action@master
        with:
          image-ref: 'app:scan'
          severity: 'CRITICAL,HIGH'
          exit-code: '1'

Security Headers Configuration

Implement comprehensive security headers to protect against common attack vectors:

const helmet = require('helmet');
 
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      defaultSrc: ["'self'"],
      scriptSrc: ["'self'", "'strict-dynamic'"],
      styleSrc: ["'self'", "'unsafe-inline'"],
      imgSrc: ["'self'", "data:", "https:"],
      connectSrc: ["'self'", "https://api.example.com"],
      frameSrc: ["'none'"],
      objectSrc: ["'none'"],
      baseUri: ["'self'"],
      formAction: ["'self'"],
      upgradeInsecureRequests: [],
    },
  },
  hsts: { maxAge: 31536000, includeSubDomains: true, preload: true },
  referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
}));

Regular security audits, penetration testing, and bug bounty programs complement your automated scanning by identifying logic flaws and complex attack chains that automated tools cannot detect.

Community Resources and Further Learning

The technology landscape evolves rapidly, making continuous learning essential for maintaining expertise. Building a systematic approach to staying current with developments in your technology stack ensures you can leverage new features and avoid deprecated patterns.

Curated Learning Pathways

Rather than consuming content randomly, create structured learning pathways aligned with your current projects and career goals. Start with official documentation and specification documents, which provide the most accurate and comprehensive information. Follow this with hands-on tutorials and workshops that reinforce concepts through practical application.

Technical blogs from framework maintainers and core team members often provide deeper insights into design decisions and upcoming features. Subscribe to the official blogs of your primary frameworks and libraries to stay ahead of breaking changes and deprecation timelines.

Contributing to Open Source

Contributing to open-source projects in your technology stack provides unparalleled learning opportunities. Start with documentation improvements and bug reports, then progress to fixing small issues tagged as "good first issue" in your favorite projects. This direct engagement with maintainers and the codebase accelerates your understanding far beyond what passive learning can achieve.

# Setting up for contribution
git clone https://github.com/project/repository.git
cd repository
git checkout -b fix/issue-description
 
# Run the project's contribution setup
npm run setup:dev
npm run test  # Ensure tests pass before making changes
 
# Make your changes, then run the full test suite
npm run test:full
npm run lint
npm run build
 
# Submit your contribution
git add -A
git commit -m "fix: description of the fix
 
Closes #1234"
git push origin fix/issue-description

Building a Technical Knowledge Base

Maintain a personal knowledge base that captures insights, solutions, and patterns you discover during your work. Tools like Obsidian, Notion, or even a simple Markdown repository can serve as an external memory that grows more valuable over time.

Organize your notes by topic rather than chronologically, and include code examples, links to relevant documentation, and explanations of why certain approaches work better than others. When you encounter a particularly insightful article or conference talk, write a summary that captures the key takeaways and how they apply to your current projects.

Follow key conferences and their published talks to stay informed about emerging patterns and best practices. Many conferences publish recorded talks on YouTube within weeks of the event, making world-class technical content freely accessible.

Join relevant Discord servers, Slack communities, and forums where practitioners discuss real-world challenges and solutions. These communities provide early warning about emerging issues and access to collective wisdom that isn't available through formal documentation.

Mentorship and Knowledge Sharing

Teaching others is one of the most effective ways to deepen your own understanding. Consider writing technical blog posts, giving talks at local meetups, or mentoring junior developers. The process of explaining concepts to others forces you to organize your knowledge and identify gaps in your understanding.

Pair programming sessions with colleagues of different experience levels create mutual learning opportunities. Senior developers gain fresh perspectives on problems they've solved the same way for years, while junior developers benefit from exposure to production-grade thinking and decision-making processes.

Conclusion

OAuth 2.1 doesn't ask you to learn new concepts. It asks you to stop using the insecure ones you already know. If your application uses the authorization code flow with PKCE, rotates refresh tokens, and matches redirect URIs exactly, you're already compliant. If not, work through the migration checklist systematically.

The changes are well-defined, the security benefits are proven, and the ecosystem tooling already supports everything OAuth 2.1 requires. Every major identity provider (Auth0, Okta, Keycloak, Azure AD) supports PKCE, refresh token rotation, and exact redirect URI matching. Migrate sooner rather than later — the longer you wait, the more technical debt accumulates and the greater your exposure to the attacks these changes prevent.