OAuth 2.0 Security: PKCE and Token Management

OAuth 2.0 was designed in an era when 'public clients' meant installed desktop applications. The implicit flow—returning tokens directly in URL fragments—seemed reasonable for JavaScript applications...

Key Insights

  • PKCE (Proof Key for Code Exchange) is now mandatory for all OAuth 2.0 clients, not just public clients—it prevents authorization code interception attacks that the implicit flow couldn’t address.
  • Token storage location matters more than encryption: memory-only storage with secure cookie refresh tokens provides the best security posture for SPAs, while mobile apps should use platform-specific secure storage.
  • Refresh token rotation combined with a request queue pattern prevents both token replay attacks and race conditions that plague naive implementations.

Why OAuth 2.0 Security Matters

OAuth 2.0 was designed in an era when “public clients” meant installed desktop applications. The implicit flow—returning tokens directly in URL fragments—seemed reasonable for JavaScript applications that couldn’t securely store client secrets. That assumption aged poorly.

Modern SPAs face browser history exposure, referrer header leakage, and sophisticated XSS attacks. Mobile apps deal with custom URL scheme hijacking and inter-app communication vulnerabilities. The implicit flow, deprecated since 2019, exposes access tokens in browser history and makes token replay trivial for attackers.

PKCE solves the authorization code interception problem, but it’s just one piece of the puzzle. Secure token management—how you store, refresh, and protect tokens—determines whether your OAuth implementation is actually secure or just appears to be.

Understanding PKCE (Proof Key for Code Exchange)

PKCE adds a dynamic secret to the authorization code flow. Instead of relying on a static client secret (which public clients can’t protect), each authorization request generates a unique cryptographic proof.

The mechanism involves two values: a code verifier (a high-entropy random string) and a code challenge (a transformation of the verifier). The client sends the challenge during authorization, keeps the verifier secret, then proves possession by sending the verifier during token exchange.

Two transformation methods exist: plain (challenge equals verifier) and S256 (challenge is the SHA-256 hash of the verifier, base64url-encoded). Never use plain—it provides no security benefit. S256 ensures that even if an attacker intercepts the authorization response, they can’t exchange the code without the original verifier.

import crypto from 'crypto';

function generatePKCE() {
  // Generate 32 bytes of random data for the verifier
  const verifierBuffer = crypto.randomBytes(32);
  const codeVerifier = verifierBuffer
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  // Create S256 challenge
  const challengeBuffer = crypto.createHash('sha256')
    .update(codeVerifier)
    .digest();
  const codeChallenge = challengeBuffer
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  return { codeVerifier, codeChallenge };
}

// Browser-compatible version using Web Crypto API
async function generatePKCEBrowser() {
  const verifierBytes = crypto.getRandomValues(new Uint8Array(32));
  const codeVerifier = btoa(String.fromCharCode(...verifierBytes))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);
  const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(hashBuffer)))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

  return { codeVerifier, codeChallenge };
}

The verifier must be between 43 and 128 characters. Using 32 random bytes produces a 43-character base64url string—the minimum length that provides sufficient entropy.

Implementing PKCE in Authorization Requests

The complete PKCE flow requires careful state management across the redirect cycle. You must store the code verifier before redirecting and retrieve it after the callback.

class PKCEAuthClient {
  constructor(config) {
    this.authorizationEndpoint = config.authorizationEndpoint;
    this.tokenEndpoint = config.tokenEndpoint;
    this.clientId = config.clientId;
    this.redirectUri = config.redirectUri;
    this.scope = config.scope;
  }

  async initiateLogin() {
    const { codeVerifier, codeChallenge } = await generatePKCEBrowser();
    const state = crypto.getRandomValues(new Uint8Array(16))
      .reduce((s, b) => s + b.toString(16).padStart(2, '0'), '');

    // Store verifier and state securely for the callback
    sessionStorage.setItem('pkce_verifier', codeVerifier);
    sessionStorage.setItem('oauth_state', state);

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

    window.location.href = `${this.authorizationEndpoint}?${params}`;
  }

  async handleCallback() {
    const params = new URLSearchParams(window.location.search);
    const code = params.get('code');
    const returnedState = params.get('state');
    const error = params.get('error');

    if (error) {
      throw new Error(`OAuth error: ${error}`);
    }

    const storedState = sessionStorage.getItem('oauth_state');
    const codeVerifier = sessionStorage.getItem('pkce_verifier');

    // Clean up immediately
    sessionStorage.removeItem('oauth_state');
    sessionStorage.removeItem('pkce_verifier');

    if (returnedState !== storedState) {
      throw new Error('State mismatch - possible CSRF attack');
    }

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

    if (!response.ok) {
      throw new Error('Token exchange failed');
    }

    return response.json();
  }
}

Critical detail: clear the stored verifier and state immediately after retrieval, even before the token exchange completes. This limits the window for extraction attacks.

Secure Token Storage Strategies

Where you store tokens determines your security ceiling. Each option has distinct trade-offs.

Memory only: Tokens exist only in JavaScript variables. Immune to XSS extraction via localStorage or cookies, but lost on page refresh. Best for high-security SPAs that can tolerate re-authentication.

sessionStorage: Survives page refreshes within the same tab. Vulnerable to XSS but isolated between tabs. Reasonable for moderate-security applications.

Secure cookies: HttpOnly cookies can’t be read by JavaScript, providing XSS protection for refresh tokens. Requires a backend component to set cookies properly.

class SecureTokenService {
  #accessToken = null;
  #tokenExpiry = null;

  // Memory-only access token storage
  setAccessToken(token, expiresIn) {
    this.#accessToken = token;
    this.#tokenExpiry = Date.now() + (expiresIn * 1000) - 30000; // 30s buffer
  }

  getAccessToken() {
    if (!this.#accessToken || Date.now() >= this.#tokenExpiry) {
      return null;
    }
    return this.#accessToken;
  }

  clear() {
    this.#accessToken = null;
    this.#tokenExpiry = null;
  }

  isTokenExpired() {
    return !this.#tokenExpiry || Date.now() >= this.#tokenExpiry;
  }
}

// Backend endpoint for setting refresh token as HttpOnly cookie
// Express.js example
app.post('/auth/token', async (req, res) => {
  const tokens = await exchangeCodeForTokens(req.body.code, req.body.codeVerifier);
  
  res.cookie('refresh_token', tokens.refresh_token, {
    httpOnly: true,
    secure: true,
    sameSite: 'strict',
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
    path: '/auth/refresh' // Limit cookie scope
  });

  // Only return access token to client
  res.json({
    access_token: tokens.access_token,
    expires_in: tokens.expires_in
  });
});

For mobile apps, use platform secure storage: Keychain on iOS, EncryptedSharedPreferences on Android. Never store tokens in plain SharedPreferences or UserDefaults.

Token Refresh and Rotation

Silent refresh keeps users authenticated without interaction. The challenge: handling concurrent requests that all detect an expired token simultaneously.

Without coordination, multiple requests trigger multiple refresh attempts. If the authorization server implements refresh token rotation (issuing a new refresh token with each use), all but one request will fail—and you might invalidate your session entirely.

class TokenRefreshManager {
  #refreshPromise = null;
  #tokenService;
  #refreshEndpoint;

  constructor(tokenService, refreshEndpoint) {
    this.#tokenService = tokenService;
    this.#refreshEndpoint = refreshEndpoint;
  }

  async getValidAccessToken() {
    const currentToken = this.#tokenService.getAccessToken();
    if (currentToken) {
      return currentToken;
    }

    // If refresh is already in progress, wait for it
    if (this.#refreshPromise) {
      await this.#refreshPromise;
      return this.#tokenService.getAccessToken();
    }

    // Start refresh and store the promise
    this.#refreshPromise = this.#performRefresh();
    
    try {
      await this.#refreshPromise;
      return this.#tokenService.getAccessToken();
    } finally {
      this.#refreshPromise = null;
    }
  }

  async #performRefresh() {
    const response = await fetch(this.#refreshEndpoint, {
      method: 'POST',
      credentials: 'include' // Send HttpOnly cookie
    });

    if (!response.ok) {
      this.#tokenService.clear();
      throw new Error('Session expired');
    }

    const data = await response.json();
    this.#tokenService.setAccessToken(data.access_token, data.expires_in);
  }
}

// Axios interceptor integration
function createAuthenticatedClient(refreshManager) {
  const client = axios.create();

  client.interceptors.request.use(async (config) => {
    const token = await refreshManager.getValidAccessToken();
    config.headers.Authorization = `Bearer ${token}`;
    return config;
  });

  client.interceptors.response.use(
    (response) => response,
    async (error) => {
      if (error.response?.status === 401 && !error.config._retry) {
        error.config._retry = true;
        await refreshManager.getValidAccessToken();
        return client(error.config);
      }
      throw error;
    }
  );

  return client;
}

The #refreshPromise pattern ensures only one refresh request executes at a time. All concurrent callers await the same promise.

Protecting Against Common Attacks

The state parameter prevents CSRF attacks where an attacker tricks a user into completing an OAuth flow with the attacker’s authorization code. Generate state with sufficient entropy and validate it strictly.

class SecureStateManager {
  #pendingStates = new Map();
  #stateTimeout = 600000; // 10 minutes

  generateState(metadata = {}) {
    const stateBytes = crypto.getRandomValues(new Uint8Array(32));
    const state = Array.from(stateBytes)
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');

    this.#pendingStates.set(state, {
      createdAt: Date.now(),
      ...metadata
    });

    // Auto-cleanup expired states
    this.#cleanupExpiredStates();

    return state;
  }

  validateState(state) {
    const stateData = this.#pendingStates.get(state);
    
    if (!stateData) {
      return { valid: false, reason: 'unknown_state' };
    }

    this.#pendingStates.delete(state); // One-time use

    if (Date.now() - stateData.createdAt > this.#stateTimeout) {
      return { valid: false, reason: 'expired_state' };
    }

    return { valid: true, metadata: stateData };
  }

  #cleanupExpiredStates() {
    const now = Date.now();
    for (const [state, data] of this.#pendingStates) {
      if (now - data.createdAt > this.#stateTimeout) {
        this.#pendingStates.delete(state);
      }
    }
  }
}

For XSS mitigation: never interpolate tokens into HTML, avoid eval and innerHTML, implement Content Security Policy headers, and use subresource integrity for third-party scripts.

Production Checklist and Best Practices

Before deploying OAuth to production, verify these configurations:

Token lifetimes: Access tokens should expire in 5-15 minutes. Refresh tokens: 24 hours for web apps, up to 30 days for mobile with rotation enabled.

Scope minimization: Request only the scopes your application actually needs. Audit scope usage quarterly.

Audience validation: Always validate the aud claim in tokens matches your application. Reject tokens intended for other clients.

Monitoring: Alert on unusual patterns—multiple failed refresh attempts, tokens used from unexpected geolocations, or sudden spikes in token issuance.

Revocation: Implement token revocation for logout and account compromise scenarios. Don’t rely solely on expiration.

HTTPS everywhere: Never transmit tokens over unencrypted connections. Use HSTS headers and consider certificate pinning for mobile apps.

OAuth 2.0 security isn’t a one-time implementation—it’s an ongoing practice. PKCE and proper token management form the foundation, but regular security audits and staying current with evolving best practices keep your implementation robust against emerging threats.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.