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:
- Client generates a cryptographically random code verifier (43-128 chars)
- Client computes a code challenge from the verifier using SHA-256
- Authorization request includes the challenge (not the verifier)
- After user authorization, the client exchanges the code + original verifier for tokens
- 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 (neverMath.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:
S256(SHA-256): Cryptographic hash of verifier (recommended, required by OpenAI)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:
- Cryptographically random state values (minimum 128 bits entropy)
- Server-side state storage with expiration (5-10 minutes)
- PKCE data binding (link state to verifier)
- 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:
- RFC 7636: Proof Key for Code Exchange - Official PKCE specification
- OAuth 2.1 Security Best Practices - IETF security guidance
- Mobile OAuth Best Practices - Native app OAuth patterns