OAuth PKCE Advanced Implementation for ChatGPT Apps

When building ChatGPT apps that require user authentication, OAuth 2.1 with Proof Key for Code Exchange (PKCE) is mandatory for protecting authorization code flows from interception attacks. While basic PKCE implementation prevents authorization code theft on compromised networks, advanced PKCE techniques address sophisticated threats including replay attacks, state manipulation, timing vulnerabilities, and mobile-specific security challenges.

This guide covers production-grade PKCE implementation patterns that go beyond standard tutorials—cryptographically secure verifier generation, S256 challenge computation with proper hashing, state parameter anti-CSRF protection, mobile deep linking security, and edge case hardening. By the end, you'll have battle-tested TypeScript code that passes OpenAI's security review and protects your users from OAuth exploitation.

Understanding PKCE Security Architecture

Proof Key for Code Exchange (PKCE) protects the OAuth authorization code flow from authorization code interception attacks, where malicious apps steal codes during redirect exchanges. Traditional OAuth flows assume confidential clients (servers) can safely store client secrets, but mobile apps and SPAs cannot hide secrets in compiled code or browser JavaScript.

PKCE solves this by requiring the client to prove it initiated the authorization request:

  1. Client generates a cryptographically random code verifier (43-128 chars)
  2. Client computes a code challenge from the verifier using SHA-256
  3. Authorization request includes the challenge (not the verifier)
  4. After user authorization, the client exchanges the code + original verifier for tokens
  5. Authorization server validates that SHA256(verifier) === stored_challenge

Even if an attacker intercepts the authorization code, they cannot exchange it without the original verifier (which never leaves the client). This transforms public clients into cryptographically protected flows without requiring client secrets.

Cryptographically Secure Code Verifier Generation

The foundation of PKCE security is the code verifier—a high-entropy random string that must be unpredictable to attackers. Weak verifier generation (predictable patterns, insufficient entropy, or non-cryptographic randomness) undermines the entire PKCE security model.

Here's a production-grade verifier generator with proper entropy validation:

// pkce-verifier-generator.ts - Cryptographically Secure Code Verifier
import crypto from 'crypto';

interface VerifierConfig {
  length?: number; // 43-128 characters (default: 128)
  minEntropy?: number; // Minimum bits of entropy (default: 256)
  charset?: 'base64url' | 'alphanumeric';
}

class PKCEVerifierGenerator {
  private static readonly MIN_LENGTH = 43;
  private static readonly MAX_LENGTH = 128;
  private static readonly DEFAULT_LENGTH = 128;
  private static readonly MIN_ENTROPY_BITS = 256;

  /**
   * Generate cryptographically secure code verifier
   *
   * @param config - Verifier configuration options
   * @returns Base64URL-encoded random string (43-128 chars)
   * @throws Error if entropy requirements not met
   */
  static generate(config: VerifierConfig = {}): string {
    const length = config.length || this.DEFAULT_LENGTH;
    const minEntropy = config.minEntropy || this.MIN_ENTROPY_BITS;
    const charset = config.charset || 'base64url';

    // Validate length constraints (RFC 7636 Section 4.1)
    if (length < this.MIN_LENGTH || length > this.MAX_LENGTH) {
      throw new Error(
        `Code verifier length must be ${this.MIN_LENGTH}-${this.MAX_LENGTH} characters`
      );
    }

    // Calculate required bytes for target entropy
    const requiredBytes = Math.ceil(minEntropy / 8);

    // Generate cryptographically secure random bytes
    const randomBytes = crypto.randomBytes(requiredBytes);

    // Encode based on charset
    let verifier: string;
    if (charset === 'base64url') {
      verifier = this.toBase64URL(randomBytes);
    } else {
      verifier = this.toAlphanumeric(randomBytes);
    }

    // Truncate or pad to exact length
    verifier = this.normalizeLength(verifier, length);

    // Validate entropy (Shannon entropy calculation)
    const actualEntropy = this.calculateEntropy(verifier);
    if (actualEntropy < minEntropy * 0.9) {
      // Recursive retry if entropy too low (should be rare)
      return this.generate(config);
    }

    return verifier;
  }

  /**
   * Convert bytes to Base64URL encoding (RFC 7636 compliant)
   * Base64URL uses A-Z, a-z, 0-9, -, _ (no padding)
   */
  private static toBase64URL(bytes: Buffer): string {
    return bytes
      .toString('base64')
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, ''); // Remove padding
  }

  /**
   * Convert bytes to alphanumeric-only charset
   * Uses A-Z, a-z, 0-9 only (62 characters)
   */
  private static toAlphanumeric(bytes: Buffer): string {
    const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
    let result = '';

    for (const byte of bytes) {
      result += charset[byte % charset.length];
    }

    return result;
  }

  /**
   * Normalize verifier to exact target length
   */
  private static normalizeLength(verifier: string, targetLength: number): string {
    if (verifier.length > targetLength) {
      return verifier.substring(0, targetLength);
    }

    // Pad with additional random chars if too short
    while (verifier.length < targetLength) {
      const extraBytes = crypto.randomBytes(16);
      verifier += this.toBase64URL(extraBytes);
    }

    return verifier.substring(0, targetLength);
  }

  /**
   * Calculate Shannon entropy (bits)
   * Measures unpredictability of the string
   */
  private static calculateEntropy(str: string): number {
    const frequencies = new Map<string, number>();

    // Count character frequencies
    for (const char of str) {
      frequencies.set(char, (frequencies.get(char) || 0) + 1);
    }

    // Calculate Shannon entropy: -Σ(p * log2(p))
    let entropy = 0;
    const length = str.length;

    for (const count of frequencies.values()) {
      const probability = count / length;
      entropy -= probability * Math.log2(probability);
    }

    return entropy * length;
  }

  /**
   * Validate existing verifier meets PKCE requirements
   */
  static validate(verifier: string): { valid: boolean; reason?: string } {
    // Length check
    if (verifier.length < this.MIN_LENGTH || verifier.length > this.MAX_LENGTH) {
      return {
        valid: false,
        reason: `Length must be ${this.MIN_LENGTH}-${this.MAX_LENGTH} chars`
      };
    }

    // Character set check (unreserved chars only)
    const validChars = /^[A-Za-z0-9\-._~]+$/;
    if (!validChars.test(verifier)) {
      return {
        valid: false,
        reason: 'Must contain only unreserved characters (A-Z, a-z, 0-9, -, ., _, ~)'
      };
    }

    // Entropy check
    const entropy = this.calculateEntropy(verifier);
    if (entropy < this.MIN_ENTROPY_BITS * 0.9) {
      return {
        valid: false,
        reason: `Insufficient entropy (${entropy.toFixed(0)} bits, need ${this.MIN_ENTROPY_BITS})`
      };
    }

    return { valid: true };
  }
}

// Usage example
const verifier = PKCEVerifierGenerator.generate({
  length: 128,
  minEntropy: 256,
  charset: 'base64url'
});

console.log('Code Verifier:', verifier);
console.log('Validation:', PKCEVerifierGenerator.validate(verifier));

Key security principles:

  • Use crypto.randomBytes() for cryptographic randomness (never Math.random())
  • Minimum 256 bits of entropy to prevent brute-force attacks
  • Base64URL encoding for URL-safe transmission without escaping
  • Shannon entropy validation ensures unpredictability
  • RFC 7636 compliance for OpenAI Apps SDK compatibility

Code Challenge Methods: S256 vs Plain

After generating the verifier, you must compute a code challenge to send in the authorization request. OAuth 2.1 supports two challenge methods:

  1. S256 (SHA-256): Cryptographic hash of verifier (recommended, required by OpenAI)
  2. plain: Verifier sent directly as challenge (deprecated, insecure)

OpenAI Apps SDK requires S256 for all ChatGPT apps. Here's a production implementation:

// pkce-challenge-creator.ts - Code Challenge Generation
import crypto from 'crypto';

type ChallengeMethod = 'S256' | 'plain';

interface ChallengeResult {
  codeChallenge: string;
  codeChallengeMethod: ChallengeMethod;
  verifier: string;
}

class PKCEChallengeCreator {
  /**
   * Create PKCE challenge from verifier
   *
   * @param verifier - Code verifier (43-128 chars)
   * @param method - Challenge method ('S256' or 'plain')
   * @returns Challenge data for authorization request
   */
  static create(
    verifier: string,
    method: ChallengeMethod = 'S256'
  ): ChallengeResult {
    // Validate verifier format
    this.validateVerifier(verifier);

    let codeChallenge: string;

    if (method === 'S256') {
      codeChallenge = this.computeS256Challenge(verifier);
    } else {
      // Plain method (not recommended, but included for completeness)
      codeChallenge = verifier;
    }

    return {
      codeChallenge,
      codeChallengeMethod: method,
      verifier // Store for token exchange
    };
  }

  /**
   * Compute S256 challenge: BASE64URL(SHA256(verifier))
   */
  private static computeS256Challenge(verifier: string): string {
    // Step 1: Compute SHA-256 hash of ASCII-encoded verifier
    const hash = crypto
      .createHash('sha256')
      .update(verifier, 'ascii')
      .digest();

    // Step 2: Base64URL encode the hash
    return this.base64URLEncode(hash);
  }

  /**
   * Base64URL encode buffer (RFC 7636 compliant)
   */
  private static base64URLEncode(buffer: Buffer): string {
    return buffer
      .toString('base64')
      .replace(/\+/g, '-')
      .replace(/\//g, '_')
      .replace(/=/g, ''); // Remove padding
  }

  /**
   * Validate verifier meets RFC 7636 requirements
   */
  private static validateVerifier(verifier: string): void {
    if (!verifier || typeof verifier !== 'string') {
      throw new Error('Verifier must be a non-empty string');
    }

    if (verifier.length < 43 || verifier.length > 128) {
      throw new Error('Verifier length must be 43-128 characters');
    }

    // Unreserved characters only (RFC 3986)
    const validPattern = /^[A-Za-z0-9\-._~]+$/;
    if (!validPattern.test(verifier)) {
      throw new Error('Verifier contains invalid characters');
    }
  }

  /**
   * Verify challenge matches verifier (for testing/validation)
   */
  static verify(
    verifier: string,
    challenge: string,
    method: ChallengeMethod
  ): boolean {
    try {
      const computed = this.create(verifier, method);
      return computed.codeChallenge === challenge;
    } catch {
      return false;
    }
  }

  /**
   * Generate complete PKCE flow data
   */
  static generateFlow(): ChallengeResult {
    // Import verifier generator from previous section
    const verifier = PKCEVerifierGenerator.generate({
      length: 128,
      charset: 'base64url'
    });

    return this.create(verifier, 'S256');
  }
}

// Usage example
const pkceData = PKCEChallengeCreator.generateFlow();

console.log('Code Verifier:', pkceData.verifier);
console.log('Code Challenge:', pkceData.codeChallenge);
console.log('Challenge Method:', pkceData.codeChallengeMethod);

// Build authorization URL
const authURL = new URL('https://oauth.example.com/authorize');
authURL.searchParams.set('response_type', 'code');
authURL.searchParams.set('client_id', 'your-client-id');
authURL.searchParams.set('code_challenge', pkceData.codeChallenge);
authURL.searchParams.set('code_challenge_method', pkceData.codeChallengeMethod);
authURL.searchParams.set('redirect_uri', 'https://chatgpt.com/connector_platform_oauth_redirect');

console.log('Authorization URL:', authURL.toString());

Why S256 is mandatory:

  • Prevents verifier exposure during network transmission
  • Mitigates TLS downgrade attacks (verifier never sent unencrypted)
  • Required by OpenAI Apps SDK for security compliance
  • Future-proof (plain method deprecated in OAuth 2.1)

State Parameter Management for CSRF Prevention

The OAuth state parameter prevents Cross-Site Request Forgery (CSRF) attacks where attackers trick users into authorizing malicious apps. Advanced state management includes:

  1. Cryptographically random state values (minimum 128 bits entropy)
  2. Server-side state storage with expiration (5-10 minutes)
  3. PKCE data binding (link state to verifier)
  4. Single-use state validation (prevent replay attacks)

Here's a production state manager with Redis persistence:

// pkce-state-manager.ts - Advanced State Parameter Management
import crypto from 'crypto';
import { createClient, RedisClientType } from 'redis';

interface StateData {
  codeVerifier: string;
  redirectUri: string;
  clientId: string;
  scope?: string;
  nonce?: string; // For OpenID Connect
  createdAt: number;
  expiresAt: number;
}

class PKCEStateManager {
  private redis: RedisClientType;
  private readonly STATE_TTL = 600; // 10 minutes
  private readonly STATE_LENGTH = 32; // 256 bits
  private readonly KEY_PREFIX = 'pkce:state:';

  constructor(redisUrl: string) {
    this.redis = createClient({ url: redisUrl });
    this.redis.connect();
  }

  /**
   * Generate new state parameter and store PKCE data
   *
   * @param data - PKCE flow data to associate with state
   * @returns State parameter for authorization request
   */
  async createState(data: Omit<StateData, 'createdAt' | 'expiresAt'>): Promise<string> {
    // Generate cryptographically secure state
    const state = crypto.randomBytes(this.STATE_LENGTH).toString('base64url');

    // Add timestamps
    const stateData: StateData = {
      ...data,
      createdAt: Date.now(),
      expiresAt: Date.now() + (this.STATE_TTL * 1000)
    };

    // Store in Redis with TTL
    const key = this.KEY_PREFIX + state;
    await this.redis.setEx(
      key,
      this.STATE_TTL,
      JSON.stringify(stateData)
    );

    return state;
  }

  /**
   * Validate state parameter and retrieve PKCE data
   *
   * @param state - State from authorization callback
   * @returns PKCE data or null if invalid/expired
   */
  async validateState(state: string): Promise<StateData | null> {
    if (!state || typeof state !== 'string') {
      return null;
    }

    const key = this.KEY_PREFIX + state;

    // Retrieve and delete (single-use)
    const dataStr = await this.redis.getDel(key);

    if (!dataStr) {
      return null; // State not found or already used
    }

    try {
      const data = JSON.parse(dataStr) as StateData;

      // Verify not expired (defense in depth)
      if (Date.now() > data.expiresAt) {
        return null;
      }

      return data;
    } catch {
      return null; // Invalid JSON
    }
  }

  /**
   * Clean up expired states (scheduled maintenance)
   */
  async cleanupExpiredStates(): Promise<number> {
    const pattern = this.KEY_PREFIX + '*';
    let cursor = 0;
    let deletedCount = 0;

    do {
      const reply = await this.redis.scan(cursor, {
        MATCH: pattern,
        COUNT: 100
      });

      cursor = reply.cursor;

      for (const key of reply.keys) {
        const dataStr = await this.redis.get(key);
        if (!dataStr) continue;

        try {
          const data = JSON.parse(dataStr) as StateData;
          if (Date.now() > data.expiresAt) {
            await this.redis.del(key);
            deletedCount++;
          }
        } catch {
          // Delete corrupted data
          await this.redis.del(key);
          deletedCount++;
        }
      }
    } while (cursor !== 0);

    return deletedCount;
  }

  /**
   * Graceful shutdown
   */
  async disconnect(): Promise<void> {
    await this.redis.quit();
  }
}

// Usage example with authorization flow
async function initiateOAuthFlow() {
  const stateManager = new PKCEStateManager('redis://localhost:6379');

  // Generate PKCE data
  const pkce = PKCEChallengeCreator.generateFlow();

  // Create state parameter
  const state = await stateManager.createState({
    codeVerifier: pkce.verifier,
    redirectUri: 'https://chatgpt.com/connector_platform_oauth_redirect',
    clientId: 'your-client-id',
    scope: 'read write'
  });

  // Build authorization URL
  const authURL = new URL('https://oauth.example.com/authorize');
  authURL.searchParams.set('response_type', 'code');
  authURL.searchParams.set('client_id', 'your-client-id');
  authURL.searchParams.set('redirect_uri', 'https://chatgpt.com/connector_platform_oauth_redirect');
  authURL.searchParams.set('scope', 'read write');
  authURL.searchParams.set('state', state);
  authURL.searchParams.set('code_challenge', pkce.codeChallenge);
  authURL.searchParams.set('code_challenge_method', 'S256');

  console.log('Authorization URL:', authURL.toString());

  // Store state in user session (optional additional security layer)
  // session.oauthState = state;

  return authURL.toString();
}

// Callback handler
async function handleOAuthCallback(code: string, state: string) {
  const stateManager = new PKCEStateManager('redis://localhost:6379');

  // Validate state (single-use, retrieves and deletes)
  const stateData = await stateManager.validateState(state);

  if (!stateData) {
    throw new Error('Invalid or expired state parameter (CSRF detected)');
  }

  // Exchange authorization code for tokens
  const tokenResponse = await fetch('https://oauth.example.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: code,
      redirect_uri: stateData.redirectUri,
      client_id: stateData.clientId,
      code_verifier: stateData.codeVerifier // PKCE verification
    })
  });

  const tokens = await tokenResponse.json();
  return tokens;
}

State management best practices:

  • Single-use states prevent replay attacks
  • Short expiration (5-10 minutes) limits attack window
  • Bind state to PKCE verifier for end-to-end validation
  • Store in server-side cache (Redis, Memcached) not cookies

Mobile App PKCE Implementation

Mobile apps introduce unique PKCE challenges: deep linking, custom URL schemes, universal links, and app-to-app redirect security. Attackers can register malicious apps with the same URL scheme to intercept authorization codes.

Here's a secure mobile implementation pattern:

// mobile-pkce-handler.ts - Mobile Deep Linking Security
import { Linking } from 'react-native';
import * as WebBrowser from 'expo-web-browser';

interface MobileOAuthConfig {
  authorizationEndpoint: string;
  tokenEndpoint: string;
  clientId: string;
  redirectUri: string; // Custom URL scheme: myapp://oauth/callback
  scopes: string[];
}

class MobilePKCEHandler {
  private config: MobileOAuthConfig;
  private currentVerifier: string | null = null;
  private currentState: string | null = null;

  constructor(config: MobileOAuthConfig) {
    this.config = config;
    this.setupDeepLinkListener();
  }

  /**
   * Initiate OAuth flow with PKCE
   */
  async authorize(): Promise<void> {
    // Generate PKCE data
    const pkce = PKCEChallengeCreator.generateFlow();
    this.currentVerifier = pkce.verifier;

    // Generate state
    this.currentState = crypto.randomBytes(32).toString('base64url');

    // Build authorization URL
    const authURL = new URL(this.config.authorizationEndpoint);
    authURL.searchParams.set('response_type', 'code');
    authURL.searchParams.set('client_id', this.config.clientId);
    authURL.searchParams.set('redirect_uri', this.config.redirectUri);
    authURL.searchParams.set('scope', this.config.scopes.join(' '));
    authURL.searchParams.set('state', this.currentState);
    authURL.searchParams.set('code_challenge', pkce.codeChallenge);
    authURL.searchParams.set('code_challenge_method', 'S256');

    // Open browser (uses SFSafariViewController on iOS, Chrome Custom Tabs on Android)
    await WebBrowser.openAuthSessionAsync(
      authURL.toString(),
      this.config.redirectUri
    );
  }

  /**
   * Setup deep link listener for OAuth callback
   */
  private setupDeepLinkListener(): void {
    // Listen for deep link events
    Linking.addEventListener('url', this.handleDeepLink.bind(this));

    // Handle app launch from deep link
    Linking.getInitialURL().then((url) => {
      if (url) {
        this.handleDeepLink({ url });
      }
    });
  }

  /**
   * Handle OAuth callback deep link
   */
  private async handleDeepLink(event: { url: string }): Promise<void> {
    const url = new URL(event.url);

    // Verify this is our OAuth callback
    if (!url.href.startsWith(this.config.redirectUri)) {
      return; // Not our callback
    }

    // Extract authorization code and state
    const code = url.searchParams.get('code');
    const state = url.searchParams.get('state');
    const error = url.searchParams.get('error');

    if (error) {
      console.error('OAuth error:', error);
      return;
    }

    if (!code || !state) {
      console.error('Missing code or state parameter');
      return;
    }

    // Validate state (CSRF protection)
    if (state !== this.currentState) {
      console.error('State mismatch (CSRF attack detected)');
      return;
    }

    // Exchange code for tokens
    await this.exchangeCodeForTokens(code);
  }

  /**
   * Exchange authorization code for access tokens
   */
  private async exchangeCodeForTokens(code: string): Promise<void> {
    if (!this.currentVerifier) {
      throw new Error('No code verifier available');
    }

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

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

    const tokens = await response.json();

    // Clear sensitive data
    this.currentVerifier = null;
    this.currentState = null;

    // Store tokens securely (use Keychain/Keystore)
    await this.storeTokensSecurely(tokens);
  }

  /**
   * Store tokens in platform-specific secure storage
   */
  private async storeTokensSecurely(tokens: any): Promise<void> {
    // Use react-native-keychain or expo-secure-store
    // Never store in AsyncStorage (not encrypted)

    // Example with expo-secure-store:
    // await SecureStore.setItemAsync('access_token', tokens.access_token);
    // await SecureStore.setItemAsync('refresh_token', tokens.refresh_token);

    console.log('Tokens stored securely');
  }
}

// Usage example
const oauthHandler = new MobilePKCEHandler({
  authorizationEndpoint: 'https://oauth.example.com/authorize',
  tokenEndpoint: 'https://oauth.example.com/token',
  clientId: 'your-mobile-client-id',
  redirectUri: 'myapp://oauth/callback', // Custom URL scheme
  scopes: ['read', 'write']
});

// Trigger OAuth flow (e.g., from login button)
oauthHandler.authorize();

Mobile security requirements:

  • Use native browser (SFSafariViewController/Chrome Custom Tabs) not embedded WebView
  • Custom URL schemes must be unique to prevent hijacking (com.yourcompany.appname://)
  • Universal Links (iOS) or App Links (Android) for production apps
  • Secure token storage (Keychain/Keystore, not AsyncStorage)

Security Edge Cases and Attack Prevention

Advanced PKCE implementations must defend against sophisticated attacks:

1. Replay Attack Prevention

// replay-attack-preventer.ts - Single-Use Code Verifiers
import { createClient } from 'redis';

class ReplayAttackPreventer {
  private redis = createClient();
  private readonly VERIFIER_TTL = 300; // 5 minutes

  constructor() {
    this.redis.connect();
  }

  /**
   * Mark verifier as used (single-use enforcement)
   */
  async markVerifierUsed(verifier: string): Promise<boolean> {
    const key = `verifier:used:${verifier}`;

    // Try to set key (fails if already exists)
    const result = await this.redis.set(key, '1', {
      NX: true, // Only set if not exists
      EX: this.VERIFIER_TTL
    });

    return result !== null; // True if successfully marked (first use)
  }

  /**
   * Validate token exchange request
   */
  async validateTokenRequest(code: string, verifier: string): Promise<boolean> {
    // Check if verifier already used
    const isFirstUse = await this.markVerifierUsed(verifier);

    if (!isFirstUse) {
      console.error('Replay attack detected: verifier already used');
      return false;
    }

    return true;
  }
}

2. Timing Attack Mitigation

// timing-attack-mitigation.ts - Constant-Time Comparison
import crypto from 'crypto';

class TimingAttackMitigation {
  /**
   * Constant-time string comparison (prevents timing attacks)
   */
  static constantTimeCompare(a: string, b: string): boolean {
    if (a.length !== b.length) {
      // Fail immediately if lengths differ (no timing leak)
      return false;
    }

    // Use crypto.timingSafeEqual for constant-time comparison
    const bufferA = Buffer.from(a);
    const bufferB = Buffer.from(b);

    try {
      return crypto.timingSafeEqual(bufferA, bufferB);
    } catch {
      return false;
    }
  }

  /**
   * Validate PKCE challenge with timing-safe comparison
   */
  static validateChallenge(verifier: string, challenge: string): boolean {
    const computedChallenge = PKCEChallengeCreator.create(verifier, 'S256').codeChallenge;
    return this.constantTimeCompare(computedChallenge, challenge);
  }
}

3. State Manipulation Detection

// state-manipulation-detector.ts - Cryptographic State Binding
import crypto from 'crypto';

class StateManipulationDetector {
  private static readonly SECRET_KEY = process.env.STATE_HMAC_SECRET!;

  /**
   * Create tamper-proof state parameter with HMAC
   */
  static createSecureState(data: object): string {
    const payload = JSON.stringify(data);
    const payloadB64 = Buffer.from(payload).toString('base64url');

    // Compute HMAC signature
    const signature = crypto
      .createHmac('sha256', this.SECRET_KEY)
      .update(payloadB64)
      .digest('base64url');

    return `${payloadB64}.${signature}`;
  }

  /**
   * Validate state hasn't been tampered with
   */
  static validateSecureState(state: string): object | null {
    const parts = state.split('.');
    if (parts.length !== 2) {
      return null;
    }

    const [payloadB64, signature] = parts;

    // Recompute signature
    const expectedSignature = crypto
      .createHmac('sha256', this.SECRET_KEY)
      .update(payloadB64)
      .digest('base64url');

    // Constant-time comparison
    if (!TimingAttackMitigation.constantTimeCompare(signature, expectedSignature)) {
      console.error('State tampering detected');
      return null;
    }

    // Decode payload
    const payload = Buffer.from(payloadB64, 'base64url').toString('utf8');
    return JSON.parse(payload);
  }
}

Complete Production PKCE Flow

Here's a complete end-to-end implementation combining all advanced techniques:

// complete-pkce-flow.ts - Production-Ready OAuth 2.1 PKCE
import express from 'express';
import session from 'express-session';

const app = express();

app.use(session({
  secret: process.env.SESSION_SECRET!,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: true, // HTTPS only
    httpOnly: true, // Prevent XSS
    sameSite: 'lax', // CSRF protection
    maxAge: 600000 // 10 minutes
  }
}));

// OAuth configuration
const OAUTH_CONFIG = {
  authorizationEndpoint: 'https://oauth.example.com/authorize',
  tokenEndpoint: 'https://oauth.example.com/token',
  clientId: process.env.OAUTH_CLIENT_ID!,
  redirectUri: 'https://yourapp.com/oauth/callback',
  scopes: ['read', 'write']
};

// Initialize state manager and replay preventer
const stateManager = new PKCEStateManager('redis://localhost:6379');
const replayPreventer = new ReplayAttackPreventer();

/**
 * Step 1: Initiate OAuth authorization
 */
app.get('/oauth/authorize', async (req, res) => {
  try {
    // Generate PKCE data
    const pkce = PKCEChallengeCreator.generateFlow();

    // Create secure state
    const stateData = {
      codeVerifier: pkce.verifier,
      redirectUri: OAUTH_CONFIG.redirectUri,
      clientId: OAUTH_CONFIG.clientId,
      scope: OAUTH_CONFIG.scopes.join(' ')
    };

    const state = await stateManager.createState(stateData);

    // Build authorization URL
    const authURL = new URL(OAUTH_CONFIG.authorizationEndpoint);
    authURL.searchParams.set('response_type', 'code');
    authURL.searchParams.set('client_id', OAUTH_CONFIG.clientId);
    authURL.searchParams.set('redirect_uri', OAUTH_CONFIG.redirectUri);
    authURL.searchParams.set('scope', OAUTH_CONFIG.scopes.join(' '));
    authURL.searchParams.set('state', state);
    authURL.searchParams.set('code_challenge', pkce.codeChallenge);
    authURL.searchParams.set('code_challenge_method', 'S256');

    // Redirect user to authorization server
    res.redirect(authURL.toString());
  } catch (error) {
    console.error('Authorization error:', error);
    res.status(500).send('Authorization failed');
  }
});

/**
 * Step 2: Handle OAuth callback
 */
app.get('/oauth/callback', async (req, res) => {
  const { code, state, error } = req.query;

  if (error) {
    return res.status(400).send(`OAuth error: ${error}`);
  }

  if (!code || !state) {
    return res.status(400).send('Missing code or state parameter');
  }

  try {
    // Validate state (single-use)
    const stateData = await stateManager.validateState(state as string);

    if (!stateData) {
      return res.status(403).send('Invalid or expired state (CSRF detected)');
    }

    // Prevent replay attacks
    const isValidRequest = await replayPreventer.validateTokenRequest(
      code as string,
      stateData.codeVerifier
    );

    if (!isValidRequest) {
      return res.status(403).send('Replay attack detected');
    }

    // Exchange authorization code for tokens
    const tokenResponse = await fetch(OAUTH_CONFIG.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: code as string,
        redirect_uri: stateData.redirectUri,
        client_id: stateData.clientId,
        code_verifier: stateData.codeVerifier
      })
    });

    if (!tokenResponse.ok) {
      const errorText = await tokenResponse.text();
      throw new Error(`Token exchange failed: ${errorText}`);
    }

    const tokens = await tokenResponse.json();

    // Store tokens in session (or database)
    req.session.accessToken = tokens.access_token;
    req.session.refreshToken = tokens.refresh_token;

    res.redirect('/dashboard');
  } catch (error) {
    console.error('Callback error:', error);
    res.status(500).send('Authentication failed');
  }
});

app.listen(3000, () => {
  console.log('OAuth server running on port 3000');
});

Build ChatGPT Apps with MakeAIHQ

Implementing production-grade OAuth 2.1 with PKCE requires deep security expertise, constant vigilance against evolving attacks, and meticulous attention to edge cases. While this guide provides battle-tested code patterns, manual implementation still carries risks—subtle bugs in cryptographic functions, state management race conditions, or mobile deep linking vulnerabilities can compromise your entire authentication flow.

MakeAIHQ automates the entire OAuth 2.1 PKCE implementation for your ChatGPT apps, with built-in security hardening that passes OpenAI's review on first submission. Our platform generates production-ready MCP servers with:

  • Cryptographically secure code verifier generation (256-bit entropy)
  • S256 challenge computation with SHA-256
  • Server-side state management with Redis persistence
  • Replay attack prevention and timing-safe comparisons
  • Mobile deep linking handlers for iOS and Android
  • Token refresh automation with secure storage
  • Complete OAuth 2.1 compliance documentation

Start building ChatGPT apps in 5 minutes →

No OAuth expertise required. No security vulnerabilities. Just production-ready ChatGPT apps that protect your users.


Related Resources:

  • OAuth 2.1 Security Implementation Guide - Complete OAuth 2.1 reference
  • OAuth PKCE Implementation for ChatGPT - Basic PKCE setup
  • OAuth Token Refresh Strategies - Token lifecycle management
  • Mobile App Security for ChatGPT - Platform-specific hardening

External References: