DEV Community

HK Lee
HK Lee

Posted on • Originally published at pockit.tools

OAuth 2.1 Is Here: What Changed, What's Deprecated, and How to Migrate Your App

If you shipped a single-page application before 2024, your OAuth implementation is probably insecure. Not "theoretically vulnerable" insecure — actually exploitable insecure.

The Implicit Grant flow that every React tutorial taught you to use? Removed in OAuth 2.1. The Resource Owner Password Credentials (ROPC) flow your mobile app relies on? Also removed. Bearer tokens in URL query strings? Banned.

OAuth 2.1 isn't a minor version bump. It's a decade of security lessons codified into spec, and it breaks real production code. Libraries you depend on are already shipping OAuth 2.1 defaults. Identity providers are deprecating legacy endpoints. If you haven't migrated yet, you're running on borrowed time.

This guide covers every breaking change in OAuth 2.1, explains why each decision was made (so you know it's not arbitrary bureaucracy), and provides production-ready TypeScript code to migrate your existing implementations.

What is OAuth 2.1, exactly?

OAuth 2.1 is not a new protocol. It's a consolidation of OAuth 2.0 (RFC 6749) plus every security best practice RFC published since 2012. Think of it as OAuth 2.0 with fourteen years of errata, security advisories, and "you should really be doing this" recommendations baked directly into the core spec.

The key RFCs it absorbs:

RFC What It Covers OAuth 2.1 Impact
RFC 7636 PKCE (Proof Key for Code Exchange) Now mandatory for all clients
RFC 7009 Token Revocation Integrated as core feature
RFC 8252 Native App Best Practices Loopback redirects standardized
RFC 9207 Authorization Server Issuer Identification Issuer verification required
RFC 9126 Pushed Authorization Requests (PAR) Recommended for high-security flows
RFC 9449 DPoP (Demonstration of Proof-of-Possession) Recommended over bearer tokens

The practical effect: one spec to read instead of six. But the migration cost is real, because OAuth 2.1 removes flows that millions of applications still use.

The four breaking changes

1. Implicit Grant is dead

What's removed: The entire response_type=token flow.

In OAuth 2.0, the Implicit Grant was designed for browser-based apps that couldn't safely store a client secret. The authorization server returned an access token directly in the URL fragment (#access_token=...). It was simple. It was also a security nightmare.

Why it's insecure:

// The Implicit Flow vulnerability chain

1. User clicks "Login with Google"
2. Redirect to: https://auth.example.com/authorize?
     response_type=token&
     client_id=my-spa&
     redirect_uri=https://app.example.com/callback

3. After authentication, redirect back:
   https://app.example.com/callback#access_token=eyJhbGciOi...

// 🚨 Problem 1: Token in URL fragment
// - Visible in browser history
// - Logged by any proxy/CDN between user and server
// - Accessible to any JavaScript on the page (XSS = game over)

// 🚨 Problem 2: No way to verify the token receiver
// - An attacker can intercept the redirect and steal the token
// - No PKCE, no code exchange, no verification step

// 🚨 Problem 3: No refresh tokens
// - Short-lived tokens mean constant re-authentication
// - Users get frustrated, developers extend token lifetimes
// - Long-lived tokens in URL fragments = worse security
Enter fullscreen mode Exit fullscreen mode

The OAuth 2.1 replacement: Authorization Code flow with PKCE. Every SPA, every mobile app, every client that used Implicit must switch.

// ❌ Old: Implicit Grant (REMOVED in OAuth 2.1)
const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'token'); // ← Banned
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
window.location.href = authUrl.toString();

// ✅ New: Authorization Code + PKCE
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);

const authUrl = new URL('https://auth.example.com/authorize');
authUrl.searchParams.set('response_type', 'code'); // ← Code, not token
authUrl.searchParams.set('client_id', CLIENT_ID);
authUrl.searchParams.set('redirect_uri', REDIRECT_URI);
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();
Enter fullscreen mode Exit fullscreen mode

2. PKCE is mandatory for ALL clients

What changed: PKCE was optional in OAuth 2.0, recommended only for public clients (SPAs, mobile apps). In OAuth 2.1, it's mandatory for every client type — including confidential server-side applications that already have a client secret.

Why even confidential clients need PKCE:

Even with a client secret, the authorization code can still be intercepted during the redirect. PKCE prevents authorization code injection attacks where an attacker substitutes their own authorization code into the victim's session. The client secret protects the token endpoint; PKCE protects the authorization flow.

How PKCE works:

import { createHash, randomBytes } from 'crypto';

// Step 1: Generate a cryptographically random code verifier
function generateCodeVerifier(): string {
  // 32 bytes = 43 characters in base64url
  return randomBytes(32)
    .toString('base64url');
}

// Step 2: Create the code challenge from the verifier
async function generateCodeChallenge(verifier: string): Promise<string> {
  // SHA-256 hash, then base64url encode
  const hash = createHash('sha256')
    .update(verifier)
    .digest();
  return Buffer.from(hash).toString('base64url');
}

// Step 3: Include in authorization request
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);

// Send challenge to authorization server
// Store verifier securely (session storage, not localStorage)
sessionStorage.setItem('pkce_verifier', verifier);

// Step 4: Include verifier in token exchange
async function exchangeCode(code: string): Promise<TokenResponse> {
  const verifier = sessionStorage.getItem('pkce_verifier');

  const response = 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: REDIRECT_URI,
      client_id: CLIENT_ID,
      code_verifier: verifier!, // ← Proves we made the original request
    }),
  });

  return response.json();
}
Enter fullscreen mode Exit fullscreen mode

The verification flow:

Client                          Auth Server
  |                                  |
  |-- code_challenge=SHA256(v) ----> |  Authorization Request
  |                                  |  (stores challenge)
  |<-------- code=abc123 ----------- |  Authorization Response
  |                                  |
  |-- code=abc123 -----------------> |  Token Request
  |   code_verifier=v                |  (computes SHA256(v),
  |                                  |   compares to stored challenge)
  |<-------- access_token ---------- |  Token Response
  |                                  |

// If an attacker intercepts the code, they can't exchange it
// because they don't have the code_verifier that matches
// the code_challenge sent in the original request.
Enter fullscreen mode Exit fullscreen mode

3. ROPC (Resource Owner Password Credentials) is dead

What's removed: The grant_type=password flow.

ROPC let applications collect the user's username and password directly and exchange them for tokens. It was designed as a "migration path" for legacy apps that couldn't redirect to an authorization server. In practice, it became a crutch that eliminated every security benefit of OAuth.

Why it was removed:

// ❌ ROPC: Your app handles raw credentials
const response = await fetch('https://auth.example.com/token', {
  method: 'POST',
  body: new URLSearchParams({
    grant_type: 'password',       // ← Removed in OAuth 2.1
    username: 'user@example.com', // ← App sees the password
    password: 'hunter2',          // ← Phishing risk, credential stuffing
    client_id: CLIENT_ID,
  }),
});

// 🚨 Problems:
// 1. App has user's raw password — violates the entire point of OAuth
// 2. No MFA support — can't do 2FA through a password grant
// 3. No consent screen — user can't control what permissions they grant
// 4. Trains users to type passwords into third-party apps
// 5. If your app is compromised, all user passwords are exposed
Enter fullscreen mode Exit fullscreen mode

The OAuth 2.1 replacement depends on your use case:

Scenario Old Flow New Flow
User login (web/mobile) ROPC Authorization Code + PKCE
Machine-to-machine auth ROPC (abused) Client Credentials
CLI tool authentication ROPC Device Authorization (RFC 8628)
Legacy system migration ROPC Token Exchange (RFC 8693)

Device Authorization flow for CLIs:

// ✅ Device Authorization Flow (replaces ROPC for CLI tools)
async function deviceLogin(): Promise<void> {
  // Step 1: Request a device code
  const deviceResponse = await fetch('https://auth.example.com/device', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: CLI_CLIENT_ID,
      scope: 'read write',
    }),
  });

  const { device_code, user_code, verification_uri, interval } =
    await deviceResponse.json();

  // Step 2: Display code to user
  console.log(`Open ${verification_uri} and enter code: ${user_code}`);

  // Step 3: Poll for completion
  while (true) {
    await sleep(interval * 1000);

    const tokenResponse = await fetch('https://auth.example.com/token', {
      method: 'POST',
      body: new URLSearchParams({
        grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
        device_code,
        client_id: CLI_CLIENT_ID,
      }),
    });

    const result = await tokenResponse.json();

    if (result.access_token) {
      saveTokens(result);
      console.log('Authenticated successfully!');
      return;
    }

    if (result.error === 'expired_token') {
      throw new Error('Login expired. Please try again.');
    }
    // 'authorization_pending' or 'slow_down' → keep polling
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Strict redirect URI matching

What changed: OAuth 2.0 allowed "loose" redirect URI matching — prefix matching, wildcard subdomains, and other flexible patterns. OAuth 2.1 requires exact string matching.

// ❌ OAuth 2.0: These "loose" patterns were allowed
// Registered: https://app.example.com/callback
// Match: https://app.example.com/callback?foo=bar     ← OK
// Match: https://app.example.com/callback/extra        ← OK (prefix match)
// Match: https://*.example.com/callback                ← OK (wildcard)

// ✅ OAuth 2.1: Exact string matching only
// Registered: https://app.example.com/callback
// Match: https://app.example.com/callback              ← OK
// No match: https://app.example.com/callback?foo=bar   ← REJECTED
// No match: https://app.example.com/callback/           ← REJECTED (trailing slash)
// No match: https://sub.example.com/callback            ← REJECTED
Enter fullscreen mode Exit fullscreen mode

Why this matters more than you think:

Open redirect vulnerabilities were one of the most common OAuth attacks. An attacker could register a redirect URI like https://evil.com/steal and, if the authorization server used prefix matching with a lax comparison, redirect the authorization code to their server.

Migration checklist:

// Audit your redirect URI registrations
const redirectUris = {
  // ❌ Problems to fix:
  bad: [
    'https://app.example.com/*',              // Wildcard — not allowed
    'https://app.example.com/auth/callback/',  // Trailing slash mismatch
    'http://localhost:3000/callback',          // HTTP in production
  ],
  // ✅ Correct registrations:
  good: [
    'https://app.example.com/auth/callback',   // Exact match, no trailing slash
    'https://staging.example.com/auth/callback', // Separate entry for staging
    'http://127.0.0.1:3000/callback',           // Loopback for dev (RFC 8252)
    'http://[::1]:3000/callback',               // IPv6 loopback for dev
  ],
};

// Note: For development, RFC 8252 allows HTTP on loopback addresses
// (127.0.0.1 and [::1]), but NOT on "localhost" (DNS resolution issue)
Enter fullscreen mode Exit fullscreen mode

Additional security requirements

Beyond the four breaking changes, OAuth 2.1 tightens several other security practices:

No bearer tokens in URLs

// ❌ OAuth 2.0 allowed this
fetch('https://api.example.com/data?access_token=eyJhbGciOi...');
// Token in URL = logged in server access logs, proxy logs, CDN logs, browser history

// ✅ OAuth 2.1: Authorization header only
fetch('https://api.example.com/data', {
  headers: {
    'Authorization': 'Bearer eyJhbGciOi...',
  },
});

// ✅ Or in POST body for form submissions
fetch('https://api.example.com/data', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    access_token: 'eyJhbGciOi...',
  }),
});
Enter fullscreen mode Exit fullscreen mode

Refresh token rotation

OAuth 2.1 strongly recommends (effectively requires) refresh token rotation. Each time a refresh token is used, the old one is invalidated and a new one is issued.

interface TokenStore {
  accessToken: string;
  refreshToken: string;
  expiresAt: number;
}

async function refreshAccessToken(store: TokenStore): Promise<TokenStore> {
  const response = await fetch('https://auth.example.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: store.refreshToken,
      client_id: CLIENT_ID,
    }),
  });

  if (!response.ok) {
    // Refresh token was already used or revoked
    // Force re-authentication
    throw new AuthenticationRequiredError('Session expired');
  }

  const data = await response.json();

  return {
    accessToken: data.access_token,
    refreshToken: data.refresh_token,  // ← New refresh token!
    expiresAt: Date.now() + data.expires_in * 1000,
  };
}

// 🚨 Critical: Handle race conditions
// If two tabs try to refresh at the same time, one will get a new
// refresh token and the other will fail because the old token was
// invalidated. Use a mutex or leader election pattern:

class TokenRefresher {
  private refreshPromise: Promise<TokenStore> | null = null;

  async getValidToken(store: TokenStore): Promise<TokenStore> {
    if (store.expiresAt > Date.now() + 30_000) {
      return store; // Still valid with 30s buffer
    }

    // Deduplicate concurrent refresh attempts
    if (!this.refreshPromise) {
      this.refreshPromise = refreshAccessToken(store).finally(() => {
        this.refreshPromise = null;
      });
    }

    return this.refreshPromise;
  }
}
Enter fullscreen mode Exit fullscreen mode

Complete migration: React SPA example

Here's a complete before/after migration for a typical React SPA that was using the Implicit Grant:

// === auth.ts — OAuth 2.1 compliant authentication module ===

const AUTH_CONFIG = {
  authority: 'https://auth.example.com',
  clientId: 'my-spa-client',
  redirectUri: 'https://app.example.com/auth/callback', // Exact match
  scope: 'openid profile email',
  tokenEndpoint: 'https://auth.example.com/token',
  authorizeEndpoint: 'https://auth.example.com/authorize',
};

// --- PKCE Utilities ---

function generateRandomString(length: number): string {
  const array = new Uint8Array(length);
  crypto.getRandomValues(array);
  return btoa(String.fromCharCode(...array))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

async function sha256(plain: string): Promise<ArrayBuffer> {
  const encoder = new TextEncoder();
  return crypto.subtle.digest('SHA-256', encoder.encode(plain));
}

async function generatePKCE(): Promise<{
  verifier: string;
  challenge: string;
}> {
  const verifier = generateRandomString(32);
  const hashed = await sha256(verifier);
  const challenge = btoa(String.fromCharCode(...new Uint8Array(hashed)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');

  return { verifier, challenge };
}

// --- Login Flow ---

export async function login(): Promise<void> {
  const { verifier, challenge } = await generatePKCE();
  const state = generateRandomString(16);

  // Store PKCE verifier and state in sessionStorage
  // (survives the redirect, clears when tab closes)
  sessionStorage.setItem('oauth_code_verifier', verifier);
  sessionStorage.setItem('oauth_state', state);

  const params = new URLSearchParams({
    response_type: 'code',
    client_id: AUTH_CONFIG.clientId,
    redirect_uri: AUTH_CONFIG.redirectUri,
    scope: AUTH_CONFIG.scope,
    state,
    code_challenge: challenge,
    code_challenge_method: 'S256',
  });

  window.location.href =
    `${AUTH_CONFIG.authorizeEndpoint}?${params.toString()}`;
}

// --- Callback Handler ---

export async function handleCallback(): Promise<{
  accessToken: string;
  refreshToken: string;
  idToken: string;
}> {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const state = params.get('state');

  // Verify state to prevent CSRF
  const savedState = sessionStorage.getItem('oauth_state');
  if (!state || state !== savedState) {
    throw new Error('Invalid state parameter — possible CSRF attack');
  }

  const verifier = sessionStorage.getItem('oauth_code_verifier');
  if (!verifier) {
    throw new Error('Missing PKCE verifier — restart login flow');
  }

  // Exchange code for tokens
  const response = await fetch(AUTH_CONFIG.tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code!,
      redirect_uri: AUTH_CONFIG.redirectUri,
      client_id: AUTH_CONFIG.clientId,
      code_verifier: verifier,
    }),
  });

  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Token exchange failed: ${error.error_description}`);
  }

  // Clean up
  sessionStorage.removeItem('oauth_code_verifier');
  sessionStorage.removeItem('oauth_state');

  // Clean URL
  window.history.replaceState({}, '', window.location.pathname);

  return response.json();
}

// --- Token Management ---

export async function refreshToken(
  currentRefreshToken: string
): Promise<{
  accessToken: string;
  refreshToken: string;
}> {
  const response = await fetch(AUTH_CONFIG.tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: currentRefreshToken,
      client_id: AUTH_CONFIG.clientId,
    }),
  });

  if (!response.ok) {
    // Refresh token rotated and already used, or revoked
    throw new Error('SESSION_EXPIRED');
  }

  return response.json();
}

// --- Logout ---

export async function logout(idToken: string): Promise<void> {
  // Revoke tokens server-side
  await fetch('https://auth.example.com/revoke', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      token: idToken,
      token_type_hint: 'access_token',
      client_id: AUTH_CONFIG.clientId,
    }),
  });

  // Clear local state and redirect
  sessionStorage.clear();
  window.location.href = 'https://auth.example.com/logout?' +
    new URLSearchParams({
      id_token_hint: idToken,
      post_logout_redirect_uri: 'https://app.example.com',
    }).toString();
}
Enter fullscreen mode Exit fullscreen mode

Migration guide by identity provider

Each major identity provider has different timelines for enforcing OAuth 2.1 semantics:

Auth0 / Okta

// Auth0 SDK v2+ already defaults to Authorization Code + PKCE
// Migration: Update the SDK and remove legacy config

// ❌ Old Auth0 configuration
const auth0 = new Auth0Client({
  domain: 'your-tenant.auth0.com',
  clientId: 'YOUR_CLIENT_ID',
  useRefreshTokens: false,     // ← Was common in Implicit setups
  cacheLocation: 'localstorage', // ← Was needed without refresh tokens
});

// ✅ New Auth0 configuration (OAuth 2.1 compliant)
const auth0 = new Auth0Client({
  domain: 'your-tenant.auth0.com',
  clientId: 'YOUR_CLIENT_ID',
  authorizationParams: {
    redirect_uri: 'https://app.example.com/callback', // Exact match
  },
  useRefreshTokens: true,      // ← Enable refresh token rotation
  cacheLocation: 'memory',     // ← In-memory is more secure
});
Enter fullscreen mode Exit fullscreen mode

Google OAuth

// Google deprecated the Implicit flow for new apps in 2022
// Existing apps: migration deadline varies

// ❌ Old: Google Sign-In (Implicit)
// <script src="https://apis.google.com/js/platform.js"></script>
// gapi.auth2.init({ client_id: '...' }) — deprecated

// ✅ New: Google Identity Services (Authorization Code + PKCE)
// Uses the new GIS library with code flow
google.accounts.oauth2.initCodeClient({
  client_id: GOOGLE_CLIENT_ID,
  scope: 'openid profile email',
  ux_mode: 'redirect',
  redirect_uri: 'https://app.example.com/auth/google/callback',
  state: generateState(),
});
Enter fullscreen mode Exit fullscreen mode

Microsoft Entra ID (Azure AD)

// MSAL.js v2+ uses Authorization Code + PKCE by default
import { PublicClientApplication } from '@azure/msal-browser';

const msalConfig = {
  auth: {
    clientId: 'YOUR_CLIENT_ID',
    authority: 'https://login.microsoftonline.com/YOUR_TENANT_ID',
    redirectUri: 'https://app.example.com/auth/callback', // Exact match
  },
  cache: {
    cacheLocation: 'sessionStorage', // ← More secure than localStorage
    storeAuthStateInCookie: false,
  },
};

const msalInstance = new PublicClientApplication(msalConfig);

// Login — PKCE is automatic with MSAL v2+
await msalInstance.loginRedirect({
  scopes: ['openid', 'profile', 'User.Read'],
});
Enter fullscreen mode Exit fullscreen mode

Security hardening beyond OAuth 2.1

OAuth 2.1 sets the floor, not the ceiling. For production apps handling sensitive data, consider these additional measures:

DPoP (Demonstration of Proof-of-Possession)

DPoP binds access tokens to a specific client, preventing token theft and replay attacks. Instead of bearer tokens (which anyone can use if stolen), DPoP tokens are cryptographically bound to the client's key pair.

// DPoP: Proof-of-Possession tokens
async function createDPoPProof(
  url: string,
  method: string,
  accessToken?: string
): Promise<string> {
  // Generate or retrieve your DPoP key pair
  const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    false,
    ['sign', 'verify']
  );

  const header = {
    alg: 'ES256',
    typ: 'dpop+jwt',
    jwk: await crypto.subtle.exportKey('jwk', keyPair.publicKey),
  };

  const payload = {
    jti: crypto.randomUUID(),
    htm: method,
    htu: url,
    iat: Math.floor(Date.now() / 1000),
    // Include access token hash for token binding
    ...(accessToken && {
      ath: await sha256Base64url(accessToken),
    }),
  };

  return signJWT(header, payload, keyPair.privateKey);
}

// Usage: Attach DPoP proof to every API request
const dpopProof = await createDPoPProof(
  'https://api.example.com/data',
  'GET',
  accessToken
);

fetch('https://api.example.com/data', {
  headers: {
    'Authorization': `DPoP ${accessToken}`,
    'DPoP': dpopProof,
  },
});
Enter fullscreen mode Exit fullscreen mode

Pushed Authorization Requests (PAR)

PAR prevents authorization request tampering by sending authorization parameters directly to the server before the redirect:

// PAR: Push authorization parameters server-side first
async function initiateLoginWithPAR(): Promise<void> {
  const { verifier, challenge } = await generatePKCE();
  const state = generateRandomString(16);

  // Step 1: Push authorization request to the server
  const parResponse = await fetch('https://auth.example.com/par', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      client_id: AUTH_CONFIG.clientId,
      redirect_uri: AUTH_CONFIG.redirectUri,
      scope: AUTH_CONFIG.scope,
      response_type: 'code',
      state,
      code_challenge: challenge,
      code_challenge_method: 'S256',
    }),
  });

  const { request_uri } = await parResponse.json();

  // Step 2: Redirect with only the request_uri
  // (All parameters are stored server-side, not in the URL)
  sessionStorage.setItem('oauth_code_verifier', verifier);
  sessionStorage.setItem('oauth_state', state);

  window.location.href =
    `${AUTH_CONFIG.authorizeEndpoint}?` +
    `client_id=${AUTH_CONFIG.clientId}&` +
    `request_uri=${encodeURIComponent(request_uri)}`;
}
Enter fullscreen mode Exit fullscreen mode

OAuth 2.1 and AI agents

One of the less-discussed implications of OAuth 2.1 is its role in the emerging AI agent ecosystem. As Model Context Protocol (MCP) servers and Agent-to-Agent (A2A) protocols mature, OAuth 2.1 provides the security foundation for delegated AI access.

// AI Agent OAuth: Scoped, time-limited access
// An AI agent should NEVER have full user permissions

const agentTokenRequest = {
  grant_type: 'authorization_code',
  code: authorizationCode,
  code_verifier: pkceVerifier,
  client_id: 'ai-agent-client',
  redirect_uri: 'https://agent.example.com/oauth/callback',

  // ← Narrow scopes for AI agents
  scope: 'read:emails read:calendar', // NOT 'write:*'

  // ← Request short-lived tokens
  // (Agent tasks shouldn't need multi-day access)
};

// For MCP server authentication:
// The MCP spec recommends OAuth 2.1 with PKCE
// for third-party tool access via AI agents.
// This ensures users explicitly consent to what
// data the AI agent can access on their behalf.
Enter fullscreen mode Exit fullscreen mode

Why this matters: An AI agent that accesses your GitHub repos, Slack channels, and email inbox needs cryptographically verifiable, narrowly scoped, time-limited authorization — not a static API key in an environment variable. OAuth 2.1 with PKCE + DPoP provides exactly this.

Common migration mistakes

Mistake 1: Storing PKCE verifier in localStorage

// ❌ Bad: localStorage persists across sessions
localStorage.setItem('pkce_verifier', verifier);
// An XSS attack can read this and complete the code exchange

// ✅ Good: sessionStorage clears when tab closes
sessionStorage.setItem('pkce_verifier', verifier);
// Still vulnerable to XSS, but shorter exposure window

// ✅ Best: Store server-side in an HTTP-only session cookie
// (for BFF / Backend-for-Frontend patterns)
Enter fullscreen mode Exit fullscreen mode

Mistake 2: Not handling refresh token race conditions

// ❌ Bad: Two concurrent API calls trigger two refresh attempts
// Tab 1: refresh_token=abc → gets new refresh_token=def
// Tab 2: refresh_token=abc → FAILS (abc was already rotated)
// Tab 2 logs the user out unnecessarily

// ✅ Good: Centralize token refresh with a mutex
class TokenManager {
  private refreshLock: Promise<void> | null = null;

  async getAccessToken(): Promise<string> {
    const tokens = this.getStoredTokens();

    if (this.isExpired(tokens)) {
      if (!this.refreshLock) {
        this.refreshLock = this.doRefresh().finally(() => {
          this.refreshLock = null;
        });
      }
      await this.refreshLock;
    }

    return this.getStoredTokens().accessToken;
  }
}
Enter fullscreen mode Exit fullscreen mode

Mistake 3: Ignoring the state parameter

// ❌ Bad: No state parameter = no CSRF protection
const authUrl = `${authEndpoint}?response_type=code&client_id=${clientId}`;
// Attacker can craft a URL that logs the victim into the attacker's account

// ✅ Good: Always generate and validate state
const state = crypto.randomUUID();
sessionStorage.setItem('oauth_state', state);
const authUrl = `${authEndpoint}?response_type=code&client_id=${clientId}&state=${state}`;

// In callback: verify state matches before exchanging code
Enter fullscreen mode Exit fullscreen mode

Mistake 4: Using "localhost" for development redirects

// ❌ Bad: "localhost" resolves via DNS (can be hijacked)
const devRedirect = 'http://localhost:3000/callback';

// ✅ Good: Use loopback IP addresses (RFC 8252)
const devRedirect = 'http://127.0.0.1:3000/callback';
// Or IPv6: 'http://[::1]:3000/callback'
// These resolve locally without DNS, preventing redirect hijacking
Enter fullscreen mode Exit fullscreen mode

The migration checklist

Use this checklist to audit your existing OAuth implementation:

## OAuth 2.1 Migration Checklist

### Critical (Must fix before IdP enforcement)
- [ ] Remove all `response_type=token` usage (Implicit Grant)
- [ ] Remove all `grant_type=password` usage (ROPC)
- [ ] Add PKCE to all authorization code flows
- [ ] Switch to exact redirect URI matching
- [ ] Remove bearer tokens from URL query strings

### High Priority
- [ ] Implement refresh token rotation
- [ ] Handle refresh token race conditions (mutex pattern)
- [ ] Store PKCE verifiers in sessionStorage, not localStorage
- [ ] Migrate development redirects from localhost to 127.0.0.1
- [ ] Add state parameter to all authorization requests

### Recommended
- [ ] Implement DPoP for high-security token binding
- [ ] Use PAR (Pushed Authorization Requests) for sensitive flows
- [ ] Scope AI agent tokens narrowly (read-only, time-limited)
- [ ] Audit third-party libraries for OAuth 2.1 compliance
- [ ] Set up token revocation on logout
Enter fullscreen mode Exit fullscreen mode

Conclusion

OAuth 2.1 isn't about adding complexity — it's about removing the sharp edges that caused a decade of security incidents. The Implicit Grant was a shortcut that created real vulnerabilities. ROPC was a migration path that became permanent. Wildcard redirects were convenient until they weren't.

The migration is straightforward for most applications:

  1. Replace Implicit Grant with Authorization Code + PKCE. This is the single most impactful change. If you're using a modern SDK (Auth0, MSAL, Firebase), updating the SDK version often handles this automatically.

  2. Remove ROPC flows. Switch to the appropriate replacement: Authorization Code for user-facing apps, Client Credentials for M2M, and Device Authorization for CLIs.

  3. Audit your redirect URIs. Register every environment (production, staging, development) as an exact-match URI. Use 127.0.0.1 instead of localhost for local development.

  4. Enable refresh token rotation. Implement the mutex pattern to handle concurrent refresh attempts across tabs.

  5. Stop putting tokens in URLs. Use the Authorization header exclusively.

The identity providers are already moving — Auth0, Okta, Google, and Microsoft have either deprecated legacy flows or set enforcement timelines. Migrating now, on your own schedule, is significantly less painful than migrating under deadline pressure when your authentication starts returning 400 errors.


Speed Tip: Read the original post on the Pockit Blog.

Tired of slow cloud tools? Pockit.tools runs entirely in your browser. Get the Extension now for instant, zero-latency access to essential dev tools.

Top comments (0)