Claude Code for OAuth2 and Authentication: Flows, PKCE, and Session Management — Claude Skills 360 Blog
Blog / Security / Claude Code for OAuth2 and Authentication: Flows, PKCE, and Session Management
Security

Claude Code for OAuth2 and Authentication: Flows, PKCE, and Session Management

Published: September 27, 2026
Read time: 9 min read
By: Claude Skills 360

Authentication bugs are high-severity: a flawed OAuth2 implementation can allow account takeover, token theft, or session fixation. Claude Code implements OAuth2 flows correctly — using PKCE for public clients, rotating refresh tokens, setting secure cookie flags, and avoiding the common mistakes that create vulnerabilities.

Authorization Code Flow with PKCE

Implement OAuth2 authorization code flow with PKCE for our SPA.
We're using Auth0 but want to understand what's happening.

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. The client generates a code verifier, sends its hash with the authorization request, and proves it held the original verifier when exchanging the code.

// src/lib/oauth.ts — PKCE implementation
import { randomBytes, createHash } from 'node:crypto';

// Step 1: Generate PKCE challenge pair
export function generatePKCE() {
  // Code verifier: random 32 bytes, base64url encoded (43-128 chars)
  const verifier = randomBytes(32)
    .toString('base64url')  // base64url = base64 without = padding, + → -, / → _
    .substring(0, 128);
  
  // Code challenge: SHA-256 hash of verifier, base64url encoded
  const challenge = createHash('sha256')
    .update(verifier)
    .digest('base64url');
  
  return { verifier, challenge };
}

// Step 2: Build authorization URL
export function buildAuthorizationUrl(params: {
  authorizationEndpoint: string;
  clientId: string;
  redirectUri: string;
  scope: string;
  state: string;
  codeChallenge: string;
}): string {
  const url = new URL(params.authorizationEndpoint);
  url.searchParams.set('response_type', 'code');
  url.searchParams.set('client_id', params.clientId);
  url.searchParams.set('redirect_uri', params.redirectUri);
  url.searchParams.set('scope', params.scope);
  url.searchParams.set('state', params.state);       // CSRF protection
  url.searchParams.set('code_challenge', params.codeChallenge);
  url.searchParams.set('code_challenge_method', 'S256');
  return url.toString();
}

// Step 3: Exchange code for tokens
export async function exchangeCodeForTokens(params: {
  tokenEndpoint: string;
  code: string;
  codeVerifier: string;  // Plain verifier, not hash
  clientId: string;
  redirectUri: string;
}): Promise<TokenResponse> {
  const response = await fetch(params.tokenEndpoint, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: params.code,
      code_verifier: params.codeVerifier,  // Authorization server hashes and compares
      client_id: params.clientId,
      redirect_uri: params.redirectUri,
    }),
  });
  
  if (!response.ok) {
    const error = await response.json();
    throw new Error(`Token exchange failed: ${error.error_description ?? error.error}`);
  }
  
  return response.json();
}

React usage:

// In React component
function LoginButton() {
  const startOAuth = () => {
    const { verifier, challenge } = generatePKCE();
    const state = randomBytes(16).toString('hex');
    
    // Store verifier and state — needed when redirect comes back
    sessionStorage.setItem('pkce_verifier', verifier);
    sessionStorage.setItem('oauth_state', state);
    
    const authUrl = buildAuthorizationUrl({
      authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
      clientId: import.meta.env.VITE_GOOGLE_CLIENT_ID,
      redirectUri: `${window.location.origin}/auth/callback`,
      scope: 'openid email profile',
      state,
      codeChallenge: challenge,
    });
    
    window.location.href = authUrl;
  };
  
  return <button onClick={startOAuth}>Sign in with Google</button>;
}

// On the callback page
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const returnedState = params.get('state');
  const storedState = sessionStorage.getItem('oauth_state');
  const verifier = sessionStorage.getItem('pkce_verifier');
  
  // Verify state matches — CSRF protection
  if (returnedState !== storedState) {
    throw new Error('State mismatch — possible CSRF attack');
  }
  
  // Clean up storage
  sessionStorage.removeItem('pkce_verifier');
  sessionStorage.removeItem('oauth_state');
  
  // Exchange code on the server (never expose client_secret to browser)
  const response = await fetch('/api/auth/callback', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ code, verifier }),
  });
  
  if (!response.ok) throw new Error('Authentication failed');
  // Server returns session cookie — we don't handle tokens in the browser
}

Server-Side Session Handling

After OAuth2, store the session securely on the server.
Don't expose tokens to JavaScript.
// src/api/auth/callback.ts — server exchanges code, stores session
import { cookies } from 'next/headers';
import { exchangeCodeForTokens } from '../../lib/oauth';
import { createSession, encryptSession } from '../../lib/sessions';

export async function POST(request: Request) {
  const { code, verifier } = await request.json();
  
  // Exchange code for tokens on server side
  const tokens = await exchangeCodeForTokens({
    tokenEndpoint: process.env.OAUTH_TOKEN_ENDPOINT!,
    code,
    codeVerifier: verifier,
    clientId: process.env.OAUTH_CLIENT_ID!,
    redirectUri: process.env.OAUTH_REDIRECT_URI!,
  });
  
  // Fetch user info with access token
  const userInfo = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  }).then(r => r.json());
  
  // Store/update user in database
  const user = await db.upsertUser({
    email: userInfo.email,
    name: userInfo.name,
    googleId: userInfo.sub,
  });
  
  // Create session — store refresh token server-side, not in cookie
  const sessionId = await createSession({
    userId: user.id,
    refreshToken: tokens.refresh_token,  // Never sent to browser
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
  });
  
  // Session ID in httpOnly cookie — JS can't access it
  cookies().set('session', sessionId, {
    httpOnly: true,       // Not accessible to JavaScript
    secure: true,         // HTTPS only
    sameSite: 'lax',      // CSRF protection
    path: '/',
    maxAge: 30 * 24 * 60 * 60,  // 30 days
  });
  
  return Response.json({ ok: true });
}

Refresh Token Rotation

Implement refresh token rotation — every refresh invalidates
the old refresh token and issues a new one.
// src/lib/sessions.ts — refresh with rotation
export async function refreshSession(sessionId: string): Promise<string | null> {
  const session = await db.sessions.findById(sessionId);
  if (!session || session.expiresAt < new Date()) return null;
  
  // Refresh the access token with the stored refresh token
  const tokenResponse = await fetch(process.env.OAUTH_TOKEN_ENDPOINT!, {
    method: 'POST',
    body: new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: session.refreshToken,
      client_id: process.env.OAUTH_CLIENT_ID!,
      client_secret: process.env.OAUTH_CLIENT_SECRET!,
    }),
  });
  
  if (!tokenResponse.ok) {
    // Refresh token expired or revoked — force re-login
    await db.sessions.delete(sessionId);
    return null;
  }
  
  const tokens = await tokenResponse.json();
  
  // Rotate: delete old session, create new one with new refresh token
  await db.sessions.delete(sessionId);
  const newSessionId = await createSession({
    userId: session.userId,
    refreshToken: tokens.refresh_token ?? session.refreshToken,  // Some providers don't rotate
    expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
  });
  
  // Detect refresh token reuse (possible token theft)
  // If old session was already invalidated and someone tries to use it:
  // This is handled by the `if (!session)` check above + our delete
  
  return newSessionId;
}

Auth Middleware

// src/middleware.ts — protect routes
export async function authMiddleware(request: Request): Promise<Response | null> {
  const sessionId = getCookie(request, 'session');
  
  if (!sessionId) {
    return redirectToLogin(request);
  }
  
  const session = await db.sessions.findById(sessionId);
  
  if (!session) {
    return redirectToLogin(request, { clearCookie: true });
  }
  
  // Refresh if session expires in < 5 minutes
  if (session.expiresAt.getTime() - Date.now() < 5 * 60 * 1000) {
    const newSessionId = await refreshSession(sessionId);
    if (!newSessionId) {
      return redirectToLogin(request, { clearCookie: true });
    }
    // Set new session cookie (handle in response headers)
  }
  
  // Session valid — attach user to request context
  request.headers.set('X-User-Id', session.userId);
  return null; // Continue to route handler
}

For the JWT internals that OAuth2 access tokens often use, see the security testing guide which includes JWT tampering tests. For applying role-based access control on top of the authentication layer, the NestJS guide shows guard patterns. The Claude Skills 360 bundle includes authentication skill sets covering OAuth2 flows, session management, and common auth vulnerabilities. Start with the free tier to try auth implementation patterns.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free