OAuth 2.1 PKCE Security: Code Challenge, Verifier & State Validation
OAuth 2.1's Proof Key for Code Exchange (PKCE) protocol extension transforms authorization code flow security by preventing authorization code interception attacks. Originally designed for mobile and single-page applications, PKCE is now mandatory for all OAuth 2.1 clients as defined in RFC 7636 and the OAuth 2.1 specification.
In this comprehensive guide, you'll learn how to implement production-grade PKCE security with code challenge generation, S256 hashing, state parameter validation, and secure token exchange. We'll provide 7 production-ready code examples totaling over 740 lines that you can deploy immediately in your ChatGPT apps, API integrations, and authentication systems.
Why PKCE Prevents Authorization Code Interception
Traditional OAuth 2.0 authorization code flow relies on client secrets to verify client identity during token exchange. This creates a critical vulnerability: public clients (mobile apps, SPAs, CLI tools) cannot securely store client secrets. An attacker intercepting the authorization code can exchange it for access tokens without the legitimate client's knowledge.
PKCE eliminates this vulnerability by introducing a dynamically generated secret (code verifier) that only the legitimate client possesses. Here's how PKCE secures the authorization flow:
- Client generates code verifier: Random 43-128 character string (cryptographically secure)
- Client derives code challenge: SHA-256 hash of verifier, base64url-encoded
- Authorization request includes challenge: Server stores challenge with authorization code
- Token exchange requires verifier: Server validates verifier matches stored challenge
- Interception attack fails: Attacker lacks the original code verifier
Unlike client secrets, the code verifier is never transmitted during authorization and cannot be derived from the challenge (one-way SHA-256 hash). This cryptographic binding makes PKCE the gold standard for OAuth 2.1 security.
PKCE is now required for:
- ChatGPT apps using OAuth 2.1 authentication (OpenAI Apps SDK compliance)
- Single-page applications (React, Vue, Svelte)
- Mobile applications (iOS, Android)
- Desktop applications and CLI tools
- Any public client that cannot securely store secrets
Let's implement production-grade PKCE security step-by-step.
Code Challenge Generation: S256 Hashing & Base64url Encoding
The foundation of PKCE security is the code challenge generation process. The client must generate a cryptographically random code verifier and derive the corresponding code challenge using SHA-256 hashing with base64url encoding.
Production-Ready PKCE Generator (TypeScript)
/**
* PKCE Generator Service
*
* Implements OAuth 2.1 PKCE (RFC 7636) code challenge generation
* with S256 transformation method and secure random verifier generation.
*
* @module pkce-generator
* @version 1.0.0
*/
import { createHash, randomBytes } from 'crypto';
export interface PKCEPair {
codeVerifier: string;
codeChallenge: string;
method: 'S256';
}
export class PKCEGenerator {
private static readonly VERIFIER_MIN_LENGTH = 43;
private static readonly VERIFIER_MAX_LENGTH = 128;
private static readonly RECOMMENDED_VERIFIER_LENGTH = 64;
/**
* Generates a complete PKCE pair (verifier + challenge)
*
* @param length - Code verifier length (default: 64, range: 43-128)
* @returns PKCE pair with verifier, challenge, and method
* @throws Error if length is outside valid range
*/
public static generatePKCEPair(
length: number = this.RECOMMENDED_VERIFIER_LENGTH
): PKCEPair {
this.validateVerifierLength(length);
const codeVerifier = this.generateCodeVerifier(length);
const codeChallenge = this.generateCodeChallenge(codeVerifier);
return {
codeVerifier,
codeChallenge,
method: 'S256'
};
}
/**
* Generates cryptographically secure code verifier
*
* Uses Node.js crypto.randomBytes for CSPRNG compliance.
* Base64url encoding ensures URL-safe characters only.
*
* @param length - Desired verifier length
* @returns Base64url-encoded random string
*/
private static generateCodeVerifier(length: number): string {
// Generate random bytes (need more bytes than target length due to base64 expansion)
const byteLength = Math.ceil((length * 3) / 4);
const randomBuffer = randomBytes(byteLength);
// Base64url encode (URL-safe, no padding)
const base64url = this.base64UrlEncode(randomBuffer);
// Truncate to exact length
return base64url.substring(0, length);
}
/**
* Generates code challenge from verifier using S256 method
*
* Transformation: BASE64URL(SHA256(ASCII(code_verifier)))
*
* @param verifier - Code verifier string
* @returns Base64url-encoded SHA-256 hash
*/
private static generateCodeChallenge(verifier: string): string {
// Create SHA-256 hash of verifier
const hash = createHash('sha256')
.update(verifier, 'ascii')
.digest();
// Base64url encode the hash
return this.base64UrlEncode(hash);
}
/**
* Base64url encoding (RFC 4648 Section 5)
*
* Standard base64 with URL-safe character substitutions:
* - Replace + with -
* - Replace / with _
* - Remove padding (=)
*
* @param buffer - Data to encode
* @returns Base64url-encoded string
*/
private static base64UrlEncode(buffer: Buffer): string {
return buffer
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
/**
* Validates code verifier length per RFC 7636
*
* @param length - Proposed verifier length
* @throws Error if length violates RFC 7636 constraints
*/
private static validateVerifierLength(length: number): void {
if (length < this.VERIFIER_MIN_LENGTH) {
throw new Error(
`Code verifier length must be at least ${this.VERIFIER_MIN_LENGTH} characters (RFC 7636)`
);
}
if (length > this.VERIFIER_MAX_LENGTH) {
throw new Error(
`Code verifier length cannot exceed ${this.VERIFIER_MAX_LENGTH} characters (RFC 7636)`
);
}
}
/**
* Verifies a code verifier matches a code challenge
*
* Used by authorization server to validate token exchange requests.
*
* @param verifier - Client-provided code verifier
* @param challenge - Server-stored code challenge
* @returns True if verifier matches challenge
*/
public static verifyCodeChallenge(verifier: string, challenge: string): boolean {
try {
const computedChallenge = this.generateCodeChallenge(verifier);
// Constant-time comparison to prevent timing attacks
return this.constantTimeEqual(computedChallenge, challenge);
} catch (error) {
// Invalid verifier format
return false;
}
}
/**
* Constant-time string comparison
*
* Prevents timing attacks by ensuring comparison takes
* the same amount of time regardless of where strings differ.
*
* @param a - First string
* @param b - Second string
* @returns True if strings are equal
*/
private static constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
}
/**
* Usage Example:
*
* // Generate PKCE pair for OAuth 2.1 authorization
* const { codeVerifier, codeChallenge, method } = PKCEGenerator.generatePKCEPair();
*
* // Store verifier securely in session
* sessionStorage.setItem('pkce_verifier', codeVerifier);
*
* // Send challenge in authorization request
* const authUrl = `https://auth.example.com/authorize?` +
* `response_type=code&` +
* `client_id=your_client_id&` +
* `redirect_uri=https://yourapp.com/callback&` +
* `code_challenge=${codeChallenge}&` +
* `code_challenge_method=${method}&` +
* `state=${stateToken}`;
*/
Key implementation details:
- Cryptographically secure random generation: Uses Node.js
crypto.randomBytesfor CSPRNG compliance - Base64url encoding: URL-safe encoding without padding characters
- S256 transformation: SHA-256 hashing ensures one-way derivation
- Constant-time comparison: Prevents timing attacks during verification
- RFC 7636 compliance: Enforces 43-128 character verifier length
Authorization Flow: Request Construction & Callback Handling
With PKCE pair generated, the client constructs the authorization request including the code challenge and state parameter. The authorization server stores the challenge and returns an authorization code upon user consent.
Production-Ready Authorization Handler (Express)
/**
* OAuth 2.1 Authorization Handler
*
* Implements PKCE-protected authorization code flow with
* state parameter validation and secure session management.
*
* @module authorization-handler
*/
import express, { Request, Response, NextFunction } from 'express';
import { PKCEGenerator } from './pkce-generator';
import { StateValidator } from './state-validator';
import { SessionManager } from './session-manager';
export class AuthorizationHandler {
private app: express.Application;
private sessionManager: SessionManager;
// OAuth 2.1 configuration
private readonly CLIENT_ID = process.env.OAUTH_CLIENT_ID!;
private readonly REDIRECT_URI = process.env.OAUTH_REDIRECT_URI!;
private readonly AUTHORIZATION_ENDPOINT = process.env.OAUTH_AUTH_ENDPOINT!;
private readonly SCOPES = ['openid', 'profile', 'email'];
constructor(app: express.Application) {
this.app = app;
this.sessionManager = new SessionManager();
this.registerRoutes();
}
/**
* Registers OAuth 2.1 authorization routes
*/
private registerRoutes(): void {
// Initiate authorization flow
this.app.get('/auth/login', this.initiateAuthorization.bind(this));
// Handle authorization callback
this.app.get('/auth/callback', this.handleCallback.bind(this));
}
/**
* Initiates OAuth 2.1 authorization with PKCE and state
*
* GET /auth/login
*/
private async initiateAuthorization(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
// Generate PKCE pair
const { codeVerifier, codeChallenge, method } = PKCEGenerator.generatePKCEPair();
// Generate state parameter for CSRF protection
const state = StateValidator.generateState();
// Store verifier and state in secure session
const sessionId = await this.sessionManager.createSession({
codeVerifier,
state,
initiatedAt: Date.now(),
returnUrl: req.query.return_url as string || '/'
});
// Set secure session cookie
res.cookie('oauth_session', sessionId, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 10 * 60 * 1000 // 10 minutes
});
// Construct authorization URL
const authUrl = new URL(this.AUTHORIZATION_ENDPOINT);
authUrl.searchParams.append('response_type', 'code');
authUrl.searchParams.append('client_id', this.CLIENT_ID);
authUrl.searchParams.append('redirect_uri', this.REDIRECT_URI);
authUrl.searchParams.append('scope', this.SCOPES.join(' '));
authUrl.searchParams.append('state', state);
authUrl.searchParams.append('code_challenge', codeChallenge);
authUrl.searchParams.append('code_challenge_method', method);
// Redirect to authorization server
res.redirect(authUrl.toString());
} catch (error) {
next(error);
}
}
/**
* Handles authorization callback with state and code validation
*
* GET /auth/callback?code=xxx&state=yyy
*/
private async handleCallback(
req: Request,
res: Response,
next: NextFunction
): Promise<void> {
try {
// Extract callback parameters
const { code, state, error, error_description } = req.query;
// Handle authorization errors
if (error) {
throw new Error(`Authorization failed: ${error} - ${error_description}`);
}
// Validate required parameters
if (!code || typeof code !== 'string') {
throw new Error('Missing authorization code');
}
if (!state || typeof state !== 'string') {
throw new Error('Missing state parameter');
}
// Retrieve session from cookie
const sessionId = req.cookies.oauth_session;
if (!sessionId) {
throw new Error('Missing OAuth session cookie');
}
const session = await this.sessionManager.getSession(sessionId);
if (!session) {
throw new Error('Invalid or expired OAuth session');
}
// Validate state parameter (CSRF protection)
if (!StateValidator.validateState(state, session.state)) {
throw new Error('State parameter mismatch (possible CSRF attack)');
}
// State validated - clear session cookie
res.clearCookie('oauth_session');
// Store authorization code and verifier for token exchange
// (Next step: token exchange handler will use these)
req.session = {
authorizationCode: code,
codeVerifier: session.codeVerifier,
returnUrl: session.returnUrl
};
// Redirect to token exchange (could be same handler or separate endpoint)
res.redirect('/auth/exchange');
} catch (error) {
// Clear session on error
res.clearCookie('oauth_session');
next(error);
}
}
}
/**
* Session Manager (simplified example - use Redis in production)
*/
class SessionManager {
private sessions: Map<string, any> = new Map();
async createSession(data: any): Promise<string> {
const sessionId = this.generateSessionId();
this.sessions.set(sessionId, data);
// Auto-expire after 10 minutes
setTimeout(() => this.sessions.delete(sessionId), 10 * 60 * 1000);
return sessionId;
}
async getSession(sessionId: string): Promise<any> {
return this.sessions.get(sessionId);
}
private generateSessionId(): string {
return PKCEGenerator.generatePKCEPair().codeVerifier;
}
}
Authorization flow security:
- PKCE pair generation: Creates verifier and challenge before redirect
- State parameter: Generates cryptographic nonce for CSRF protection
- Secure session storage: Stores verifier and state server-side (never exposed to client)
- 10-minute session expiry: Limits authorization window
- HttpOnly cookies: Prevents XSS attacks from stealing session tokens
State Parameter: CSRF Protection & Validation
The state parameter prevents Cross-Site Request Forgery (CSRF) attacks where an attacker tricks a victim into completing an OAuth flow initiated by the attacker. The client generates a random state value, includes it in the authorization request, and validates it matches on callback.
Production-Ready State Validator (TypeScript)
/**
* OAuth 2.1 State Validator
*
* Implements CSRF protection via state parameter validation.
* Prevents authorization code injection and session fixation attacks.
*
* @module state-validator
*/
import { randomBytes, createHmac } from 'crypto';
export interface StateMetadata {
nonce: string;
timestamp: number;
clientId?: string;
sessionId?: string;
}
export class StateValidator {
private static readonly STATE_LENGTH = 32; // 256 bits
private static readonly STATE_EXPIRY_MS = 10 * 60 * 1000; // 10 minutes
private static readonly HMAC_SECRET = process.env.STATE_HMAC_SECRET!;
/**
* Generates cryptographically secure state parameter
*
* Format: base64url(nonce || timestamp || hmac)
*
* @param metadata - Optional metadata to bind to state
* @returns State parameter string
*/
public static generateState(metadata?: Partial<StateMetadata>): string {
// Generate random nonce
const nonce = randomBytes(this.STATE_LENGTH).toString('base64url');
// Current timestamp (for expiry validation)
const timestamp = Date.now();
// Create state payload
const payload: StateMetadata = {
nonce,
timestamp,
...metadata
};
// Serialize payload
const payloadString = JSON.stringify(payload);
const payloadBase64 = Buffer.from(payloadString).toString('base64url');
// Generate HMAC signature (prevents tampering)
const signature = createHmac('sha256', this.HMAC_SECRET)
.update(payloadBase64)
.digest('base64url');
// Combine payload and signature
return `${payloadBase64}.${signature}`;
}
/**
* Validates state parameter from callback
*
* Checks:
* 1. Signature validity (prevents tampering)
* 2. Timestamp freshness (prevents replay attacks)
* 3. Nonce uniqueness (application-specific)
*
* @param receivedState - State from authorization callback
* @param expectedState - State stored in session
* @returns True if state is valid
*/
public static validateState(
receivedState: string,
expectedState: string
): boolean {
try {
// States must match exactly (constant-time comparison)
if (!this.constantTimeEqual(receivedState, expectedState)) {
console.error('State mismatch: received !== expected');
return false;
}
// Parse state structure
const [payloadBase64, signature] = receivedState.split('.');
if (!payloadBase64 || !signature) {
console.error('Invalid state format: missing payload or signature');
return false;
}
// Verify HMAC signature
const expectedSignature = createHmac('sha256', this.HMAC_SECRET)
.update(payloadBase64)
.digest('base64url');
if (!this.constantTimeEqual(signature, expectedSignature)) {
console.error('State signature verification failed (possible tampering)');
return false;
}
// Decode payload
const payloadString = Buffer.from(payloadBase64, 'base64url').toString('utf-8');
const payload: StateMetadata = JSON.parse(payloadString);
// Validate timestamp (prevent replay attacks)
const age = Date.now() - payload.timestamp;
if (age > this.STATE_EXPIRY_MS) {
console.error(`State expired: age=${age}ms, max=${this.STATE_EXPIRY_MS}ms`);
return false;
}
if (age < 0) {
console.error('State timestamp is in the future (clock skew or attack)');
return false;
}
// All validations passed
return true;
} catch (error) {
console.error('State validation error:', error);
return false;
}
}
/**
* Extracts metadata from valid state parameter
*
* @param state - Validated state parameter
* @returns State metadata object
*/
public static extractMetadata(state: string): StateMetadata | null {
try {
const [payloadBase64] = state.split('.');
const payloadString = Buffer.from(payloadBase64, 'base64url').toString('utf-8');
return JSON.parse(payloadString);
} catch (error) {
return null;
}
}
/**
* Constant-time string comparison (prevents timing attacks)
*/
private static constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) {
return false;
}
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
}
State validation security:
- HMAC signature: Prevents state parameter tampering
- Timestamp validation: Prevents replay attacks (10-minute expiry)
- Constant-time comparison: Prevents timing attacks
- Metadata binding: Can bind state to session ID or client ID
Token Exchange: Code Verifier Validation & Token Issuance
After successful authorization callback with valid state, the client exchanges the authorization code for access tokens. The token request must include the code verifier (the original random string, not the challenge). The authorization server validates the verifier matches the stored challenge before issuing tokens.
Production-Ready Token Exchange Handler (TypeScript)
/**
* OAuth 2.1 Token Exchange Handler
*
* Implements PKCE-protected token exchange with code verifier validation.
*
* @module token-exchange
*/
import axios, { AxiosError } from 'axios';
import { PKCEGenerator } from './pkce-generator';
export interface TokenResponse {
access_token: string;
token_type: 'Bearer';
expires_in: number;
refresh_token?: string;
scope: string;
id_token?: string;
}
export interface TokenExchangeRequest {
authorizationCode: string;
codeVerifier: string;
redirectUri: string;
}
export class TokenExchangeHandler {
private readonly TOKEN_ENDPOINT = process.env.OAUTH_TOKEN_ENDPOINT!;
private readonly CLIENT_ID = process.env.OAUTH_CLIENT_ID!;
/**
* Exchanges authorization code for access tokens
*
* POST /oauth/token
* Content-Type: application/x-www-form-urlencoded
*
* @param request - Token exchange parameters
* @returns Token response with access_token and refresh_token
* @throws Error on validation failure or server error
*/
public async exchangeCodeForTokens(
request: TokenExchangeRequest
): Promise<TokenResponse> {
try {
// Construct token request payload
const payload = new URLSearchParams({
grant_type: 'authorization_code',
code: request.authorizationCode,
redirect_uri: request.redirectUri,
client_id: this.CLIENT_ID,
code_verifier: request.codeVerifier // PKCE verification
});
// Send token request
const response = await axios.post<TokenResponse>(
this.TOKEN_ENDPOINT,
payload.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
timeout: 10000 // 10 second timeout
}
);
// Validate token response
this.validateTokenResponse(response.data);
return response.data;
} catch (error) {
this.handleTokenExchangeError(error);
throw error; // TypeScript flow analysis
}
}
/**
* Validates token response structure
*/
private validateTokenResponse(response: TokenResponse): void {
if (!response.access_token) {
throw new Error('Missing access_token in token response');
}
if (response.token_type !== 'Bearer') {
throw new Error(`Invalid token_type: ${response.token_type} (expected Bearer)`);
}
if (!response.expires_in || response.expires_in <= 0) {
throw new Error('Invalid or missing expires_in');
}
// Validate token format (basic check - proper validation requires parsing)
if (response.access_token.length < 20) {
throw new Error('Access token appears invalid (too short)');
}
}
/**
* Handles token exchange errors with detailed logging
*/
private handleTokenExchangeError(error: unknown): never {
if (axios.isAxiosError(error)) {
const axiosError = error as AxiosError<any>;
if (axiosError.response) {
// OAuth 2.1 error response
const { error: errorCode, error_description } = axiosError.response.data || {};
switch (errorCode) {
case 'invalid_grant':
throw new Error(
'Authorization code invalid or expired. ' +
'User may have taken too long or code was already used.'
);
case 'invalid_request':
throw new Error(
`Token request invalid: ${error_description || 'Missing required parameters'}`
);
case 'unauthorized_client':
throw new Error('Client not authorized for this grant type');
case 'unsupported_grant_type':
throw new Error('Authorization code grant type not supported');
default:
throw new Error(
`Token exchange failed: ${errorCode} - ${error_description || 'Unknown error'}`
);
}
} else if (axiosError.request) {
// Network error (no response received)
throw new Error(
'Token endpoint unreachable. Check network connectivity and endpoint URL.'
);
}
}
// Unknown error
throw new Error(`Token exchange failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Authorization Server-Side: Code Verifier Validation
*
* This is what the authorization server does when receiving token request.
*/
export class AuthorizationServerValidator {
private codeStorage: Map<string, { challenge: string; expiresAt: number }> = new Map();
/**
* Stores code challenge when issuing authorization code
*/
public storeCodeChallenge(
authorizationCode: string,
codeChallenge: string,
expirySeconds: number = 600 // 10 minutes
): void {
this.codeStorage.set(authorizationCode, {
challenge: codeChallenge,
expiresAt: Date.now() + (expirySeconds * 1000)
});
}
/**
* Validates code verifier during token exchange
*
* @param authorizationCode - Authorization code from token request
* @param codeVerifier - Code verifier from token request
* @returns True if verifier is valid
*/
public validateCodeVerifier(
authorizationCode: string,
codeVerifier: string
): boolean {
// Retrieve stored challenge
const stored = this.codeStorage.get(authorizationCode);
if (!stored) {
console.error('Authorization code not found or already used');
return false;
}
// Check expiry
if (Date.now() > stored.expiresAt) {
console.error('Authorization code expired');
this.codeStorage.delete(authorizationCode);
return false;
}
// Validate verifier matches challenge
const isValid = PKCEGenerator.verifyCodeChallenge(codeVerifier, stored.challenge);
// Single-use authorization code: delete after validation attempt
this.codeStorage.delete(authorizationCode);
return isValid;
}
}
Token exchange security:
- Code verifier transmission: Sent in token request (server validates against challenge)
- Single-use authorization codes: Deleted after first token exchange attempt
- 10-minute code expiry: Limits authorization code lifetime
- Detailed error handling: Differentiates between invalid_grant, network errors, etc.
- Response validation: Ensures token_type=Bearer and valid expires_in
CSRF Protection Middleware
Beyond state parameter validation, production OAuth implementations should include defense-in-depth CSRF protections at the middleware level.
Production-Ready CSRF Middleware (Express)
/**
* CSRF Protection Middleware for OAuth 2.1
*
* Defense-in-depth protection beyond state parameter validation.
*
* @module csrf-middleware
*/
import { Request, Response, NextFunction } from 'express';
import { createHmac, randomBytes } from 'crypto';
export class CSRFMiddleware {
private readonly SECRET = process.env.CSRF_SECRET!;
private readonly COOKIE_NAME = 'csrf_token';
private readonly HEADER_NAME = 'X-CSRF-Token';
/**
* Generates CSRF token and sets cookie
*
* Use on routes that render OAuth initiation forms.
*/
public generateToken() {
return (req: Request, res: Response, next: NextFunction): void => {
// Generate random token
const token = randomBytes(32).toString('base64url');
// Generate HMAC signature
const signature = this.signToken(token);
const signedToken = `${token}.${signature}`;
// Set secure cookie
res.cookie(this.COOKIE_NAME, signedToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 3600000 // 1 hour
});
// Make token available to templates
res.locals.csrfToken = token;
next();
};
}
/**
* Validates CSRF token from header or body
*
* Use on OAuth initiation endpoints (POST /auth/login).
*/
public validateToken() {
return (req: Request, res: Response, next: NextFunction): void => {
try {
// Extract token from header or body
const submittedToken =
req.headers[this.HEADER_NAME.toLowerCase()] as string ||
req.body.csrf_token;
if (!submittedToken) {
throw new Error('Missing CSRF token');
}
// Extract signed token from cookie
const signedToken = req.cookies[this.COOKIE_NAME];
if (!signedToken) {
throw new Error('Missing CSRF cookie');
}
// Parse cookie token
const [cookieToken, signature] = signedToken.split('.');
if (!cookieToken || !signature) {
throw new Error('Invalid CSRF cookie format');
}
// Verify signature
const expectedSignature = this.signToken(cookieToken);
if (!this.constantTimeEqual(signature, expectedSignature)) {
throw new Error('CSRF cookie signature invalid');
}
// Verify submitted token matches cookie token
if (!this.constantTimeEqual(submittedToken, cookieToken)) {
throw new Error('CSRF token mismatch');
}
// Valid - continue
next();
} catch (error) {
res.status(403).json({
error: 'csrf_validation_failed',
error_description: error instanceof Error ? error.message : 'CSRF validation failed'
});
}
};
}
/**
* Signs CSRF token with HMAC
*/
private signToken(token: string): string {
return createHmac('sha256', this.SECRET)
.update(token)
.digest('base64url');
}
/**
* Constant-time comparison
*/
private constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let result = 0;
for (let i = 0; i < a.length; i++) {
result |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return result === 0;
}
}
Token Rotation & Refresh Security
OAuth 2.1 mandates refresh token rotation to prevent refresh token theft. When a refresh token is used to obtain new access tokens, the authorization server issues a new refresh token and invalidates the old one.
Production-Ready Token Rotation Service (TypeScript)
/**
* OAuth 2.1 Token Rotation Service
*
* Implements refresh token rotation per OAuth 2.1 spec.
* Detects and prevents refresh token replay attacks.
*
* @module token-rotation
*/
import axios from 'axios';
import { TokenResponse } from './token-exchange';
export interface RefreshTokenRequest {
refreshToken: string;
scope?: string[];
}
export class TokenRotationService {
private readonly TOKEN_ENDPOINT = process.env.OAUTH_TOKEN_ENDPOINT!;
private readonly CLIENT_ID = process.env.OAUTH_CLIENT_ID!;
// Refresh token family tracking (detects token replay)
private tokenFamilies: Map<string, Set<string>> = new Map();
/**
* Refreshes access token using refresh token
*
* OAuth 2.1 behavior:
* - Issues new access_token
* - Issues new refresh_token (rotation)
* - Invalidates old refresh_token
*
* @param request - Refresh token parameters
* @returns New token response
*/
public async refreshAccessToken(
request: RefreshTokenRequest
): Promise<TokenResponse> {
try {
// Check for token replay attack
this.detectTokenReplay(request.refreshToken);
// Construct refresh request
const payload = new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: request.refreshToken,
client_id: this.CLIENT_ID
});
// Add scope if specified (optional downscoping)
if (request.scope && request.scope.length > 0) {
payload.append('scope', request.scope.join(' '));
}
// Send refresh request
const response = await axios.post<TokenResponse>(
this.TOKEN_ENDPOINT,
payload.toString(),
{
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json'
},
timeout: 10000
}
);
// Track new refresh token in family
if (response.data.refresh_token) {
this.trackTokenRotation(request.refreshToken, response.data.refresh_token);
}
return response.data;
} catch (error) {
// Token replay detected or refresh failed - revoke entire family
this.revokeTokenFamily(request.refreshToken);
throw error;
}
}
/**
* Detects refresh token replay attacks
*
* If a refresh token is used twice, it indicates:
* 1. Token theft (attacker using stolen token)
* 2. Client bug (improper token storage)
*
* OAuth 2.1 requires revoking the entire token family.
*/
private detectTokenReplay(refreshToken: string): void {
// Find token family
for (const [familyId, tokens] of this.tokenFamilies.entries()) {
if (tokens.has(refreshToken)) {
// Check if this token was already used (rotated)
const tokenCount = tokens.size;
// If family has multiple tokens, this is a replay
// (the latest token should be the only valid one)
if (tokenCount > 1) {
console.error(`Token replay detected in family ${familyId}`);
throw new Error('Refresh token replay detected - possible token theft');
}
return;
}
}
}
/**
* Tracks refresh token rotation within a family
*/
private trackTokenRotation(oldToken: string, newToken: string): void {
// Find existing family or create new one
let familyId: string | undefined;
for (const [id, tokens] of this.tokenFamilies.entries()) {
if (tokens.has(oldToken)) {
familyId = id;
break;
}
}
if (!familyId) {
// New token family
familyId = this.generateFamilyId();
}
// Get or create token set
const tokenSet = this.tokenFamilies.get(familyId) || new Set<string>();
// Remove old token (it's now invalid)
tokenSet.delete(oldToken);
// Add new token
tokenSet.add(newToken);
// Update family
this.tokenFamilies.set(familyId, tokenSet);
}
/**
* Revokes entire token family (on replay detection or user logout)
*/
private revokeTokenFamily(refreshToken: string): void {
// Find and delete family
for (const [familyId, tokens] of this.tokenFamilies.entries()) {
if (tokens.has(refreshToken)) {
this.tokenFamilies.delete(familyId);
console.warn(`Revoked token family ${familyId} (${tokens.size} tokens)`);
return;
}
}
}
/**
* Generates unique family ID
*/
private generateFamilyId(): string {
return `family_${Date.now()}_${Math.random().toString(36).substring(7)}`;
}
}
Security Audit Logging
Production OAuth implementations must log security-relevant events for audit trails and threat detection.
Production-Ready Security Audit Logger (TypeScript)
/**
* OAuth 2.1 Security Audit Logger
*
* Logs security-relevant events for compliance and threat detection.
*
* @module security-audit-logger
*/
import { createHash } from 'crypto';
export enum AuditEventType {
AUTHORIZATION_INITIATED = 'authorization_initiated',
AUTHORIZATION_COMPLETED = 'authorization_completed',
AUTHORIZATION_FAILED = 'authorization_failed',
TOKEN_EXCHANGE_SUCCESS = 'token_exchange_success',
TOKEN_EXCHANGE_FAILED = 'token_exchange_failed',
TOKEN_REFRESH_SUCCESS = 'token_refresh_success',
TOKEN_REFRESH_FAILED = 'token_refresh_failed',
TOKEN_REPLAY_DETECTED = 'token_replay_detected',
STATE_VALIDATION_FAILED = 'state_validation_failed',
PKCE_VALIDATION_FAILED = 'pkce_validation_failed',
CSRF_ATTACK_DETECTED = 'csrf_attack_detected',
TOKEN_REVOKED = 'token_revoked'
}
export interface AuditEvent {
eventType: AuditEventType;
timestamp: number;
userId?: string;
sessionId?: string;
ipAddress?: string;
userAgent?: string;
metadata?: Record<string, any>;
severity: 'info' | 'warning' | 'critical';
}
export class SecurityAuditLogger {
/**
* Logs security audit event
*/
public static log(event: AuditEvent): void {
// Enhance event with anonymized identifiers
const auditRecord = {
...event,
timestamp: event.timestamp || Date.now(),
hashedIp: event.ipAddress ? this.hashIdentifier(event.ipAddress) : undefined,
hashedUserId: event.userId ? this.hashIdentifier(event.userId) : undefined
};
// Log to appropriate destination based on severity
switch (event.severity) {
case 'critical':
console.error('[SECURITY AUDIT - CRITICAL]', JSON.stringify(auditRecord));
// In production: send to SIEM, trigger alerts
break;
case 'warning':
console.warn('[SECURITY AUDIT - WARNING]', JSON.stringify(auditRecord));
break;
case 'info':
console.info('[SECURITY AUDIT - INFO]', JSON.stringify(auditRecord));
break;
}
// Store in audit database (implement based on your infrastructure)
this.storeAuditEvent(auditRecord);
}
/**
* Hash identifiers for privacy compliance
*/
private static hashIdentifier(identifier: string): string {
return createHash('sha256').update(identifier).digest('hex').substring(0, 16);
}
/**
* Store audit event in database
*/
private static storeAuditEvent(event: any): void {
// Implement based on your database (MongoDB, PostgreSQL, etc.)
// Example: await db.collection('audit_logs').insertOne(event);
}
}
Security Best Practices Summary
Implementing production-grade OAuth 2.1 PKCE security requires following these critical best practices:
Code Verifier & Challenge Management
- Generate verifiers with CSPRNG: Use
crypto.randomBytes(Node.js) or equivalent - Minimum 43 characters: RFC 7636 requires 43-128 character verifiers
- Store verifier server-side: Never expose verifier in client-side JavaScript
- S256 transformation only: Plain-text code_challenge_method is deprecated in OAuth 2.1
State Parameter Security
- Always include state: Required for CSRF protection
- Cryptographically random: Use CSPRNG for state generation
- HMAC signature: Sign state to prevent tampering
- 10-minute expiry: Limit state parameter lifetime
- Single-use validation: Invalidate state after first use
Token Exchange Protection
- Validate code verifier: Server must verify verifier matches stored challenge
- Single-use authorization codes: Delete code after first exchange attempt
- 10-minute code expiry: Limit authorization code lifetime
- Refresh token rotation: Issue new refresh token on every refresh request
- Detect token replay: Revoke entire token family on replay detection
Defense-in-Depth Measures
- SameSite cookies: Set
SameSite=LaxorSameSite=Stricton session cookies - HttpOnly cookies: Prevent XSS attacks from stealing cookies
- Secure flag: Require HTTPS for cookie transmission (production only)
- CSRF middleware: Additional CSRF protection beyond state parameter
- Rate limiting: Prevent brute-force attacks on token endpoints
- Audit logging: Log all security-relevant events for threat detection
Conclusion: Deploy Production-Grade PKCE Security
OAuth 2.1's PKCE protocol extension transforms authorization code flow security by eliminating the client secret vulnerability. By implementing cryptographically secure code verifiers, S256 code challenges, state parameter validation, and token rotation, you create a defense-in-depth security architecture that protects against:
- Authorization code interception attacks
- CSRF and session fixation attacks
- Refresh token theft and replay attacks
- Token tampering and forgery
The 7 production-ready code examples in this guide (740+ lines total) provide everything you need to implement OAuth 2.1 PKCE security in your ChatGPT apps, API integrations, and authentication systems.
Ready to Build Secure ChatGPT Apps?
MakeAIHQ.com automatically generates OAuth 2.1-compliant authentication for your ChatGPT apps with built-in PKCE security, state validation, and token rotation. No manual OAuth implementation required.
Create your first secure ChatGPT app in under 5 minutes:
- Sign up free at MakeAIHQ.com
- Choose OAuth 2.1 template (PKCE pre-configured)
- Deploy to ChatGPT Store with one click
Related Resources
Pillar Pages
- OAuth 2.1 Complete Implementation Guide - Master OAuth 2.1 authorization flows
- ChatGPT App Security Deep Dive - OpenAI Apps SDK security requirements
- API Authentication Best Practices - Enterprise authentication patterns
Related Cluster Articles
- OAuth 2.1 Token Validation: JWT, Signature Verification & Expiry
- OAuth 2.1 Scope Management: Dynamic Permissions & Access Control
- OpenAI Apps SDK OAuth Compliance Checklist
- CSRF Protection Strategies for OAuth 2.1 Flows
- Refresh Token Security: Rotation, Revocation & Replay Detection
External Resources
- RFC 7636: Proof Key for Code Exchange (PKCE) - Official PKCE specification
- OAuth 2.1 Draft Specification - Latest OAuth 2.1 draft
- OWASP CSRF Prevention Cheat Sheet - CSRF protection guide
Published: December 25, 2026 Last Updated: December 25, 2026 Reading Time: 11 minutes Author: MakeAIHQ Security Team