Published on

OAuth 2.0 With PKCE — Secure Authorization Code Flow for SPAs and Mobile Apps

Authors

Introduction

OAuth 2.0's authorization code flow is the gold standard for delegated authentication. PKCE (Proof Key for Public Clients) addresses the vulnerability in the original flow: public clients (browser and mobile apps) cannot securely store client secrets.

PKCE adds a cryptographic proof layer. The client creates a code verifier, hashes it into a code challenge, and proves possession of the original verifier when exchanging the authorization code for tokens. This prevents authorization code interception attacks.

Let's implement a complete production OAuth 2.0 + PKCE flow.

PKCE Code Verifier and Challenge Generation

PKCE starts with generating a cryptographically secure code verifier and deriving its challenge.

// lib/pkce.ts
import crypto from 'crypto';

export interface PKCEPair {
  codeVerifier: string;
  codeChallenge: string;
  codeChallengeMethod: 'S256';
}

export function generatePKCEPair(): PKCEPair {
  // Code verifier: 43-128 URL-safe characters
  const codeVerifier = crypto
    .randomBytes(32)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  // Code challenge: SHA256 hash of verifier (S256 method)
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  return {
    codeVerifier,
    codeChallenge,
    codeChallengeMethod: 'S256',
  };
}

// Verify that verifier matches stored challenge
export function verifyCodeChallenge(
  codeVerifier: string,
  storedChallenge: string
): boolean {
  const computedChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  return computedChallenge === storedChallenge;
}

Authorization Code Flow Step-by-Step

Step 1: Generate PKCE pair and state, redirect to authorization endpoint.

// lib/oauth.ts
import { generatePKCEPair } from './pkce';

const OAUTH_PROVIDER = {
  authorizationEndpoint: 'https://oauth.example.com/authorize',
  tokenEndpoint: 'https://oauth.example.com/token',
  userInfoEndpoint: 'https://oauth.example.com/userinfo',
};

export interface OAuthState {
  codeVerifier: string;
  state: string;
  redirectUri: string;
  createdAt: number;
}

export function generateAuthorizationURL(
  clientId: string,
  redirectUri: string,
  scopes: string[]
): { authUrl: string; state: string; codeVerifier: string } {
  const { codeVerifier, codeChallenge } = generatePKCEPair();
  const state = crypto.randomBytes(16).toString('hex');

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

  return {
    authUrl: `${OAUTH_PROVIDER.authorizationEndpoint}?${params.toString()}`,
    state,
    codeVerifier,
  };
}

Client-side redirect:

// pages/login.tsx
export default function LoginPage() {
  async function handleOAuthLogin() {
    const { authUrl, state, codeVerifier } = generateAuthorizationURL(
      process.env.NEXT_PUBLIC_OAUTH_CLIENT_ID!,
      `${window.location.origin}/api/oauth/callback`,
      ['openid', 'profile', 'email']
    );

    // Store state and verifier in sessionStorage (not localStorage for security)
    sessionStorage.setItem('oauth_state', state);
    sessionStorage.setItem('oauth_code_verifier', codeVerifier);

    window.location.href = authUrl;
  }

  return <button onClick={handleOAuthLogin}>Sign in with OAuth</button>;
}

State Parameter and CSRF Protection

The state parameter prevents CSRF attacks where a malicious site tricks your browser into authorizing their app.

// pages/api/oauth/callback.ts
import { NextApiRequest, NextApiResponse } from 'next';
import crypto from 'crypto';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
  const { code, state, error } = req.query as Record<string, string>;

  if (error) {
    return res.status(400).json({ error: 'OAuth error: ' + error });
  }

  // CSRF check: verify state matches what we issued
  const storedState = req.cookies.oauth_state;
  if (state !== storedState) {
    return res.status(401).json({ error: 'State mismatch - possible CSRF' });
  }

  // Retrieve code verifier (sent via secure cookie, not query param)
  const codeVerifier = req.cookies.oauth_code_verifier;
  if (!codeVerifier) {
    return res.status(400).json({ error: 'Missing code verifier' });
  }

  try {
    // Exchange code for tokens
    const response = await fetch(OAUTH_PROVIDER.tokenEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        client_id: process.env.OAUTH_CLIENT_ID!,
        client_secret: process.env.OAUTH_CLIENT_SECRET!, // Back-end only
        code,
        redirect_uri: `${process.env.NEXT_PUBLIC_APP_URL}/api/oauth/callback`,
        code_verifier: codeVerifier, // PKCE: prove we own the verifier
      }).toString(),
    });

    if (!response.ok) {
      throw new Error(`Token exchange failed: ${response.statusText}`);
    }

    const tokens = await response.json();

    // Clear sensitive cookies
    res.setHeader('Set-Cookie', [
      `oauth_state=; Path=/; HttpOnly; Secure; Max-Age=0`,
      `oauth_code_verifier=; Path=/; HttpOnly; Secure; Max-Age=0`,
    ]);

    // Store tokens securely (see below)
    await storeTokens(tokens);

    return res.redirect(302, '/dashboard');
  } catch (err) {
    return res.status(500).json({ error: 'Token exchange failed' });
  }
}

Never store access tokens in localStorage—it's vulnerable to XSS. Use secure httpOnly cookies or in-memory storage with refresh token rotation.

// lib/tokenStorage.ts
import { cookies } from 'next/headers';

export interface Tokens {
  accessToken: string;
  refreshToken?: string;
  idToken?: string;
  expiresIn: number;
}

const TOKEN_COOKIE_OPTIONS = {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax' as const,
  path: '/',
};

// Strategy 1: HttpOnly cookies (server-side retrieval)
export async function storeTokensInCookie(tokens: Tokens) {
  const cookieStore = await cookies();

  cookieStore.set(
    'accessToken',
    tokens.accessToken,
    {
      ...TOKEN_COOKIE_OPTIONS,
      maxAge: tokens.expiresIn,
    }
  );

  if (tokens.refreshToken) {
    cookieStore.set(
      'refreshToken',
      tokens.refreshToken,
      {
        ...TOKEN_COOKIE_OPTIONS,
        maxAge: 30 * 24 * 60 * 60, // 30 days
      }
    );
  }

  if (tokens.idToken) {
    // ID token (not sensitive; can be read by JS)
    cookieStore.set(
      'idToken',
      tokens.idToken,
      {
        httpOnly: false,
        secure: true,
        sameSite: 'lax',
        path: '/',
        maxAge: tokens.expiresIn,
      }
    );
  }
}

// Strategy 2: In-memory + refresh rotation (SPA approach)
let inMemoryAccessToken: string | null = null;
let inMemoryTokenExpiry: number | null = null;

export async function storeTokensInMemory(tokens: Tokens) {
  inMemoryAccessToken = tokens.accessToken;
  inMemoryTokenExpiry = Date.now() + tokens.expiresIn * 1000;

  // Refresh token stored in httpOnly cookie only
  const cookieStore = await cookies();
  if (tokens.refreshToken) {
    cookieStore.set('refreshToken', tokens.refreshToken, {
      ...TOKEN_COOKIE_OPTIONS,
      maxAge: 30 * 24 * 60 * 60,
    });
  }
}

export async function getAccessToken(): Promise<string | null> {
  if (!inMemoryAccessToken) return null;

  // Check if expired; refresh if needed
  if (inMemoryTokenExpiry && Date.now() > inMemoryTokenExpiry - 60000) {
    // 1 minute before actual expiry, refresh
    await refreshAccessToken();
  }

  return inMemoryAccessToken;
}

Token Refresh and Rotation

Implement refresh token rotation to detect and prevent token theft.

// lib/tokenRefresh.ts
export async function refreshAccessToken(): Promise<Tokens | null> {
  const cookieStore = await cookies();
  const refreshToken = cookieStore.get('refreshToken')?.value;

  if (!refreshToken) {
    return null; // No refresh token; user must re-authenticate
  }

  try {
    const response = await fetch(OAUTH_PROVIDER.tokenEndpoint, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        client_id: process.env.OAUTH_CLIENT_ID!,
        client_secret: process.env.OAUTH_CLIENT_SECRET!,
        refresh_token: refreshToken,
      }).toString(),
    });

    if (!response.ok) {
      // Refresh token expired or invalid; user must re-authenticate
      cookieStore.delete('refreshToken');
      return null;
    }

    const newTokens = (await response.json()) as Tokens;

    // Rotation: old refresh token becomes invalid
    await storeTokensInCookie(newTokens);

    return newTokens;
  } catch (err) {
    console.error('Token refresh failed:', err);
    return null;
  }
}

Scope Design and Permissions

Design scopes to follow the principle of least privilege.

// OAuth scope constants
export const OAUTH_SCOPES = {
  PROFILE: 'profile', // Name, picture
  EMAIL: 'email',
  OPENID: 'openid', // ID token
  OFFLINE: 'offline_access', // Refresh token
  CUSTOM_API: 'api:read api:write', // Custom API scopes
};

// Scope groups for different use cases
export const SCOPE_GROUPS = {
  READ_ONLY: [OAUTH_SCOPES.OPENID, OAUTH_SCOPES.PROFILE],
  READ_WRITE: [
    OAUTH_SCOPES.OPENID,
    OAUTH_SCOPES.EMAIL,
    OAUTH_SCOPES.CUSTOM_API,
  ],
  OFFLINE_ACCESS: [
    OAUTH_SCOPES.OPENID,
    OAUTH_SCOPES.OFFLINE,
    OAUTH_SCOPES.CUSTOM_API,
  ],
};

// Request scopes based on feature
export function getRequiredScopes(featureSet: 'basic' | 'premium'): string[] {
  if (featureSet === 'basic') {
    return SCOPE_GROUPS.READ_ONLY;
  }
  return SCOPE_GROUPS.READ_WRITE;
}

OAuth 2.1 Simplifications

OAuth 2.1 spec (draft) removes insecure flows and mandates best practices:

// OAuth 2.1 compliant implementation checklist:
// ✓ PKCE required for all clients (not just public)
// ✓ Implicit flow removed (no token in URL)
// ✓ Resource owner password flow removed
// ✓ Redirect URI must be exact match (no wildcard)
// ✓ State parameter required
// ✓ Bearer token expiry required
// ✓ HTTPS required

const OAuth21Config = {
  requirePKCE: true, // Always
  allowedResponseTypes: ['code'], // Only code, not 'token' or 'id_token'
  redirectUriMustBeExactMatch: true,
  requireState: true,
  requireHttps: true,
  tokenExpiryMax: 3600, // 1 hour for access tokens
};

Common Implementation Mistakes

Avoid these pitfalls:

// ❌ WRONG: Storing access token in localStorage
localStorage.setItem('accessToken', token); // XSS vulnerability!

// ✓ RIGHT: httpOnly cookie (automatic sending with requests)
res.setHeader(
  'Set-Cookie',
  `accessToken=${token}; HttpOnly; Secure; SameSite=Lax`
);

// ❌ WRONG: No CSRF protection
const code = req.query.code;
await exchangeCodeForToken(code); // What if attacker sent this code?

// ✓ RIGHT: Verify state parameter
if (req.query.state !== req.cookies.oauthState) {
  throw new Error('CSRF detected');
}

// ❌ WRONG: Sending client secret from browser
fetch('/api/token', {
  body: JSON.stringify({
    client_id: PUBLIC_ID,
    client_secret: SECRET, // EXPOSED!
  }),
});

// ✓ RIGHT: Token exchange happens only on backend
// Frontend sends code to /api/oauth/callback
// Backend uses client_secret securely

// ❌ WRONG: No token refresh; user logged out after 1 hour
const accessToken = tokens.accessToken; // Fixed, will expire

// ✓ RIGHT: Auto-refresh on expiry
const token = await getAccessToken(); // Checks expiry, refreshes if needed

Conclusion

PKCE transformed OAuth 2.0 from a spec vulnerable to public client attacks into a secure, production-grade authorization framework. Implement it correctly: generate proper PKCE pairs, verify state parameters, store tokens securely in httpOnly cookies, rotate refresh tokens, and follow OAuth 2.1 simplifications.

Your users deserve secure authentication. Build it with PKCE.