OAuth Token Refresh Strategies for ChatGPT Apps

Implementing secure token refresh is critical for ChatGPT apps that maintain authenticated sessions. Access tokens have short lifespans (typically 15-60 minutes) for security, requiring refresh strategies that maintain user sessions without compromising security or user experience. This guide provides production-ready implementations for token refresh, rotation, silent refresh, and secure storage patterns that comply with OAuth 2.1 and ChatGPT App Store security requirements.

Token lifecycle management is one of the most critical aspects of OAuth implementation. When an access token expires, your ChatGPT app must seamlessly obtain a new one without disrupting the user's conversation flow. Poor refresh strategies lead to authentication errors mid-conversation, forcing users to re-authenticate and losing conversation context. Proper implementation ensures users never experience authentication interruptions while maintaining the security benefits of short-lived access tokens.

OAuth 2.1 refresh token flows provide the foundation for secure token lifecycle management. The refresh token grant type allows your app to obtain new access tokens using a long-lived refresh token, avoiding repeated user authentication. However, this convenience introduces security considerations: refresh tokens are powerful credentials that must be protected with encryption, rotation policies, and secure storage mechanisms. This guide covers all aspects of production-grade token refresh implementation.

Refresh Token Implementation

The refresh token grant type is defined in OAuth 2.1 as the mechanism for obtaining new access tokens without user interaction. When your authorization server issues an access token, it also provides a refresh token that your ChatGPT app stores securely. When the access token expires (or proactively before expiration), your app exchanges the refresh token for a new access token and optionally a new refresh token.

Here's a production-ready token refresh handler that implements the complete refresh flow with error handling, retry logic, and token rotation:

// src/auth/token-refresh-handler.ts
import crypto from 'crypto';

interface TokenResponse {
  access_token: string;
  refresh_token?: string;
  expires_in: number;
  token_type: string;
  scope?: string;
}

interface TokenMetadata {
  access_token: string;
  refresh_token: string;
  expires_at: number;
  token_type: string;
  scope: string;
}

interface RefreshOptions {
  maxRetries?: number;
  retryDelay?: number;
  rotateRefreshToken?: boolean;
}

export class TokenRefreshHandler {
  private tokenEndpoint: string;
  private clientId: string;
  private clientSecret: string;
  private tokenStorage: TokenStorage;

  constructor(
    tokenEndpoint: string,
    clientId: string,
    clientSecret: string,
    tokenStorage: TokenStorage
  ) {
    this.tokenEndpoint = tokenEndpoint;
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.tokenStorage = tokenStorage;
  }

  /**
   * Refresh access token using refresh token grant
   * Implements token rotation and retry logic
   */
  async refreshAccessToken(
    userId: string,
    options: RefreshOptions = {}
  ): Promise<TokenMetadata> {
    const {
      maxRetries = 3,
      retryDelay = 1000,
      rotateRefreshToken = true
    } = options;

    const currentTokens = await this.tokenStorage.getTokens(userId);
    if (!currentTokens?.refresh_token) {
      throw new Error('No refresh token available for user');
    }

    let lastError: Error | null = null;

    for (let attempt = 0; attempt < maxRetries; attempt++) {
      try {
        const tokenResponse = await this.executeTokenRefresh(
          currentTokens.refresh_token
        );

        const newTokens = this.processTokenResponse(
          tokenResponse,
          currentTokens.refresh_token,
          rotateRefreshToken
        );

        await this.tokenStorage.storeTokens(userId, newTokens);

        // Log successful refresh (for monitoring)
        console.log(`Token refresh successful for user ${userId} (attempt ${attempt + 1})`);

        return newTokens;

      } catch (error) {
        lastError = error as Error;
        console.warn(`Token refresh attempt ${attempt + 1} failed:`, error);

        // Don't retry on certain errors
        if (this.isNonRetryableError(error)) {
          break;
        }

        // Wait before retry
        if (attempt < maxRetries - 1) {
          await this.delay(retryDelay * Math.pow(2, attempt));
        }
      }
    }

    // All retries failed
    await this.handleRefreshFailure(userId, lastError!);
    throw new Error(`Token refresh failed after ${maxRetries} attempts: ${lastError?.message}`);
  }

  /**
   * Execute the actual token refresh HTTP request
   */
  private async executeTokenRefresh(
    refreshToken: string
  ): Promise<TokenResponse> {
    const body = new URLSearchParams({
      grant_type: 'refresh_token',
      refresh_token: refreshToken,
      client_id: this.clientId,
      client_secret: this.clientSecret
    });

    const response = await fetch(this.tokenEndpoint, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
        'Accept': 'application/json'
      },
      body: body.toString()
    });

    if (!response.ok) {
      const errorBody = await response.json().catch(() => ({}));
      throw new TokenRefreshError(
        errorBody.error || 'token_refresh_failed',
        errorBody.error_description || `HTTP ${response.status}`,
        response.status
      );
    }

    return await response.json();
  }

  /**
   * Process token response and apply rotation policy
   */
  private processTokenResponse(
    response: TokenResponse,
    oldRefreshToken: string,
    rotateRefreshToken: boolean
  ): TokenMetadata {
    const expiresAt = Date.now() + (response.expires_in * 1000);

    return {
      access_token: response.access_token,
      refresh_token: rotateRefreshToken && response.refresh_token
        ? response.refresh_token
        : oldRefreshToken,
      expires_at: expiresAt,
      token_type: response.token_type,
      scope: response.scope || ''
    };
  }

  /**
   * Determine if error should not be retried
   */
  private isNonRetryableError(error: any): boolean {
    if (error instanceof TokenRefreshError) {
      // Don't retry invalid_grant (token revoked/expired)
      // Don't retry invalid_client (configuration error)
      return ['invalid_grant', 'invalid_client'].includes(error.errorCode);
    }
    return false;
  }

  /**
   * Handle complete refresh failure
   */
  private async handleRefreshFailure(
    userId: string,
    error: Error
  ): Promise<void> {
    // Clear stored tokens
    await this.tokenStorage.clearTokens(userId);

    // Log security event
    console.error(`Token refresh failed for user ${userId}:`, error);

    // Emit event for application to handle (e.g., force re-authentication)
    this.emit('refresh_failed', { userId, error });
  }

  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }

  private emit(event: string, data: any): void {
    // Implementation depends on your event system
    // Could use EventEmitter, custom pub/sub, etc.
  }
}

class TokenRefreshError extends Error {
  constructor(
    public errorCode: string,
    message: string,
    public statusCode: number
  ) {
    super(message);
    this.name = 'TokenRefreshError';
  }
}

Token rotation is a critical security practice where each refresh operation returns a new refresh token, invalidating the old one. This prevents refresh token replay attacks if a token is compromised. The implementation above supports optional rotation via the rotateRefreshToken parameter, which should always be enabled in production environments.

Silent Token Refresh

Silent token refresh ensures your ChatGPT app maintains valid access tokens without user interaction or conversation disruption. This strategy proactively refreshes tokens before expiration, implements background refresh workers, and handles edge cases like network failures during refresh operations.

Here's a production-ready silent refresh manager that monitors token expiration and automatically refreshes tokens:

// src/auth/silent-refresh-manager.ts
export interface SilentRefreshConfig {
  refreshThresholdSeconds: number; // Refresh when this many seconds remain
  checkIntervalSeconds: number; // How often to check token expiration
  enableBackgroundRefresh: boolean; // Enable worker-based refresh
}

export class SilentRefreshManager {
  private refreshHandler: TokenRefreshHandler;
  private config: SilentRefreshConfig;
  private activeTimers: Map<string, NodeJS.Timeout> = new Map();
  private refreshLocks: Map<string, Promise<TokenMetadata>> = new Map();

  constructor(
    refreshHandler: TokenRefreshHandler,
    config: Partial<SilentRefreshConfig> = {}
  ) {
    this.refreshHandler = refreshHandler;
    this.config = {
      refreshThresholdSeconds: config.refreshThresholdSeconds || 300, // 5 minutes
      checkIntervalSeconds: config.checkIntervalSeconds || 60, // 1 minute
      enableBackgroundRefresh: config.enableBackgroundRefresh ?? true
    };
  }

  /**
   * Start monitoring tokens for a user
   */
  startMonitoring(userId: string, tokens: TokenMetadata): void {
    // Clear existing timer if any
    this.stopMonitoring(userId);

    if (this.config.enableBackgroundRefresh) {
      const timer = setInterval(
        () => this.checkAndRefresh(userId, tokens),
        this.config.checkIntervalSeconds * 1000
      );

      this.activeTimers.set(userId, timer);

      // Immediate check
      this.checkAndRefresh(userId, tokens);
    }
  }

  /**
   * Stop monitoring tokens for a user
   */
  stopMonitoring(userId: string): void {
    const timer = this.activeTimers.get(userId);
    if (timer) {
      clearInterval(timer);
      this.activeTimers.delete(userId);
    }
    this.refreshLocks.delete(userId);
  }

  /**
   * Check if token needs refresh and execute if needed
   */
  private async checkAndRefresh(
    userId: string,
    tokens: TokenMetadata
  ): Promise<void> {
    const secondsUntilExpiry = (tokens.expires_at - Date.now()) / 1000;

    if (secondsUntilExpiry <= this.config.refreshThresholdSeconds) {
      console.log(
        `Token expires in ${secondsUntilExpiry}s for user ${userId}, refreshing...`
      );

      try {
        await this.executeRefresh(userId);
      } catch (error) {
        console.error(`Silent refresh failed for user ${userId}:`, error);
        // Don't throw - let the error handler deal with it
      }
    }
  }

  /**
   * Execute token refresh with locking to prevent concurrent refreshes
   */
  private async executeRefresh(userId: string): Promise<TokenMetadata> {
    // Check if refresh is already in progress
    const existingRefresh = this.refreshLocks.get(userId);
    if (existingRefresh) {
      console.log(`Refresh already in progress for user ${userId}, waiting...`);
      return await existingRefresh;
    }

    // Create new refresh promise
    const refreshPromise = this.refreshHandler.refreshAccessToken(userId, {
      maxRetries: 2,
      rotateRefreshToken: true
    });

    // Store in locks map
    this.refreshLocks.set(userId, refreshPromise);

    try {
      const newTokens = await refreshPromise;

      // Update monitoring with new tokens
      this.startMonitoring(userId, newTokens);

      return newTokens;
    } finally {
      // Always remove lock
      this.refreshLocks.delete(userId);
    }
  }

  /**
   * Get token with automatic refresh if expired
   * Use this in API interceptors
   */
  async getValidToken(
    userId: string,
    currentTokens: TokenMetadata
  ): Promise<string> {
    const secondsUntilExpiry = (currentTokens.expires_at - Date.now()) / 1000;

    // Token already expired or expires very soon
    if (secondsUntilExpiry <= 30) {
      const newTokens = await this.executeRefresh(userId);
      return newTokens.access_token;
    }

    // Token still valid
    return currentTokens.access_token;
  }

  /**
   * Proactively refresh token (e.g., before long-running operation)
   */
  async proactiveRefresh(userId: string): Promise<TokenMetadata> {
    console.log(`Proactive refresh requested for user ${userId}`);
    return await this.executeRefresh(userId);
  }

  /**
   * Stop all monitoring (e.g., on application shutdown)
   */
  stopAll(): void {
    for (const userId of this.activeTimers.keys()) {
      this.stopMonitoring(userId);
    }
  }
}

The silent refresh manager implements several critical patterns: refresh locking prevents concurrent refresh operations for the same user, proactive refresh triggers before actual expiration to avoid race conditions, and background monitoring continuously checks token status. This ensures your ChatGPT app never makes API calls with expired tokens.

Secure Token Storage

Secure token storage protects refresh tokens and access tokens from theft and unauthorized access. Tokens are sensitive credentials that must be encrypted at rest, transmitted only over HTTPS, and never exposed to client-side JavaScript in browser environments. The storage strategy depends on your deployment architecture: server-side MCP servers, browser-based widgets, or hybrid architectures.

Here's a production-ready secure token storage implementation with encryption and multiple storage backends:

// src/auth/secure-token-storage.ts
import crypto from 'crypto';

export interface TokenStorage {
  getTokens(userId: string): Promise<TokenMetadata | null>;
  storeTokens(userId: string, tokens: TokenMetadata): Promise<void>;
  clearTokens(userId: string): Promise<void>;
}

export class EncryptedTokenStorage implements TokenStorage {
  private encryptionKey: Buffer;
  private algorithm: string = 'aes-256-gcm';
  private backend: StorageBackend;

  constructor(encryptionKey: string, backend: StorageBackend) {
    // Derive 32-byte key from passphrase
    this.encryptionKey = crypto.scryptSync(encryptionKey, 'salt', 32);
    this.backend = backend;
  }

  /**
   * Retrieve and decrypt tokens for user
   */
  async getTokens(userId: string): Promise<TokenMetadata | null> {
    const encryptedData = await this.backend.get(this.getStorageKey(userId));

    if (!encryptedData) {
      return null;
    }

    try {
      const decrypted = this.decrypt(encryptedData);
      return JSON.parse(decrypted);
    } catch (error) {
      console.error(`Failed to decrypt tokens for user ${userId}:`, error);
      // Clear corrupted data
      await this.clearTokens(userId);
      return null;
    }
  }

  /**
   * Encrypt and store tokens for user
   */
  async storeTokens(userId: string, tokens: TokenMetadata): Promise<void> {
    const serialized = JSON.stringify(tokens);
    const encrypted = this.encrypt(serialized);

    await this.backend.set(
      this.getStorageKey(userId),
      encrypted,
      this.getTTL(tokens)
    );
  }

  /**
   * Clear tokens for user
   */
  async clearTokens(userId: string): Promise<void> {
    await this.backend.delete(this.getStorageKey(userId));
  }

  /**
   * Encrypt data using AES-256-GCM
   */
  private encrypt(plaintext: string): string {
    const iv = crypto.randomBytes(16);
    const cipher = crypto.createCipheriv(this.algorithm, this.encryptionKey, iv);

    let encrypted = cipher.update(plaintext, 'utf8', 'hex');
    encrypted += cipher.final('hex');

    const authTag = cipher.getAuthTag();

    // Format: iv:authTag:ciphertext
    return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
  }

  /**
   * Decrypt data using AES-256-GCM
   */
  private decrypt(ciphertext: string): string {
    const parts = ciphertext.split(':');
    if (parts.length !== 3) {
      throw new Error('Invalid encrypted data format');
    }

    const iv = Buffer.from(parts[0], 'hex');
    const authTag = Buffer.from(parts[1], 'hex');
    const encrypted = parts[2];

    const decipher = crypto.createDecipheriv(this.algorithm, this.encryptionKey, iv);
    decipher.setAuthTag(authTag);

    let decrypted = decipher.update(encrypted, 'hex', 'utf8');
    decrypted += decipher.final('utf8');

    return decrypted;
  }

  private getStorageKey(userId: string): string {
    return `tokens:${userId}`;
  }

  private getTTL(tokens: TokenMetadata): number {
    // TTL in seconds until refresh token expires
    // Assume refresh tokens valid for 30 days
    return 30 * 24 * 60 * 60;
  }
}

/**
 * Storage backend interface
 */
export interface StorageBackend {
  get(key: string): Promise<string | null>;
  set(key: string, value: string, ttlSeconds?: number): Promise<void>;
  delete(key: string): Promise<void>;
}

/**
 * Redis-based storage backend
 */
export class RedisStorageBackend implements StorageBackend {
  private client: any; // RedisClient type

  constructor(redisClient: any) {
    this.client = redisClient;
  }

  async get(key: string): Promise<string | null> {
    return await this.client.get(key);
  }

  async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
    if (ttlSeconds) {
      await this.client.setex(key, ttlSeconds, value);
    } else {
      await this.client.set(key, value);
    }
  }

  async delete(key: string): Promise<void> {
    await this.client.del(key);
  }
}

/**
 * In-memory storage backend (for development/testing)
 */
export class MemoryStorageBackend implements StorageBackend {
  private storage: Map<string, { value: string; expiresAt?: number }> = new Map();

  async get(key: string): Promise<string | null> {
    const entry = this.storage.get(key);

    if (!entry) {
      return null;
    }

    if (entry.expiresAt && Date.now() > entry.expiresAt) {
      this.storage.delete(key);
      return null;
    }

    return entry.value;
  }

  async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
    const entry: { value: string; expiresAt?: number } = { value };

    if (ttlSeconds) {
      entry.expiresAt = Date.now() + (ttlSeconds * 1000);
    }

    this.storage.set(key, entry);
  }

  async delete(key: string): Promise<void> {
    this.storage.delete(key);
  }
}

The encrypted storage implementation uses AES-256-GCM authenticated encryption, ensuring both confidentiality and integrity of stored tokens. The modular backend design allows swapping storage mechanisms (Redis, database, memory) without changing encryption logic. Never store tokens in browser localStorage or sessionStorage for ChatGPT apps—use HttpOnly cookies for browser contexts or server-side storage for MCP servers.

Token Rotation Implementation

Token rotation policies enhance security by limiting the lifetime of refresh tokens. Each time a refresh token is used, the authorization server issues a new refresh token and invalidates the old one. This prevents refresh token replay attacks and limits the damage if a refresh token is compromised.

Here's a production-ready token rotation implementation with detection of replay attacks:

// src/auth/token-rotation-manager.ts
export interface RotationPolicy {
  enableRotation: boolean;
  detectReplayAttacks: boolean;
  revokeOnReplay: boolean;
  gracePeriodSeconds: number; // Allow old token briefly during rotation
}

export class TokenRotationManager {
  private policy: RotationPolicy;
  private usedTokens: Map<string, number> = new Map(); // token -> timestamp
  private cleanupInterval: NodeJS.Timeout;

  constructor(policy: Partial<RotationPolicy> = {}) {
    this.policy = {
      enableRotation: policy.enableRotation ?? true,
      detectReplayAttacks: policy.detectReplayAttacks ?? true,
      revokeOnReplay: policy.revokeOnReplay ?? true,
      gracePeriodSeconds: policy.gracePeriodSeconds || 60
    };

    // Cleanup used tokens map periodically
    this.cleanupInterval = setInterval(
      () => this.cleanupUsedTokens(),
      60000 // Every minute
    );
  }

  /**
   * Validate refresh token before use
   * Detects replay attacks
   */
  async validateRefreshToken(
    refreshToken: string,
    userId: string
  ): Promise<void> {
    if (!this.policy.detectReplayAttacks) {
      return;
    }

    const tokenHash = this.hashToken(refreshToken);
    const lastUsed = this.usedTokens.get(tokenHash);

    if (lastUsed) {
      const secondsSinceUse = (Date.now() - lastUsed) / 1000;

      // Token used recently (within grace period)
      if (secondsSinceUse <= this.policy.gracePeriodSeconds) {
        console.warn(
          `Refresh token reused within grace period for user ${userId} ` +
          `(${secondsSinceUse}s ago)`
        );
        return;
      }

      // Replay attack detected!
      console.error(
        `SECURITY: Refresh token replay detected for user ${userId} ` +
        `(used ${secondsSinceUse}s ago)`
      );

      if (this.policy.revokeOnReplay) {
        // Revoke all tokens for this user
        await this.revokeAllUserTokens(userId);
      }

      throw new ReplayAttackError(
        `Refresh token replay detected for user ${userId}`
      );
    }

    // Mark token as used
    this.usedTokens.set(tokenHash, Date.now());
  }

  /**
   * Process token rotation after successful refresh
   */
  async processRotation(
    oldRefreshToken: string,
    newRefreshToken: string | undefined,
    userId: string
  ): Promise<string> {
    if (!this.policy.enableRotation) {
      return oldRefreshToken;
    }

    if (!newRefreshToken) {
      throw new Error('Token rotation enabled but no new refresh token received');
    }

    const oldTokenHash = this.hashToken(oldRefreshToken);

    // Mark old token as used permanently
    this.usedTokens.set(oldTokenHash, Date.now());

    // Schedule cleanup of old token after grace period
    setTimeout(
      () => this.usedTokens.delete(oldTokenHash),
      (this.policy.gracePeriodSeconds + 300) * 1000 // Grace + 5 minutes
    );

    console.log(`Token rotation completed for user ${userId}`);

    return newRefreshToken;
  }

  /**
   * Hash token for storage (never store raw tokens)
   */
  private hashToken(token: string): string {
    return crypto
      .createHash('sha256')
      .update(token)
      .digest('hex');
  }

  /**
   * Clean up old entries from used tokens map
   */
  private cleanupUsedTokens(): void {
    const cutoff = Date.now() - (this.policy.gracePeriodSeconds + 300) * 1000;
    let cleaned = 0;

    for (const [hash, timestamp] of this.usedTokens.entries()) {
      if (timestamp < cutoff) {
        this.usedTokens.delete(hash);
        cleaned++;
      }
    }

    if (cleaned > 0) {
      console.log(`Cleaned up ${cleaned} expired token hashes`);
    }
  }

  /**
   * Revoke all tokens for a user (replay attack response)
   */
  private async revokeAllUserTokens(userId: string): Promise<void> {
    // Implementation depends on your revocation strategy
    console.log(`Revoking all tokens for user ${userId} due to replay attack`);
    // Call revocation endpoint, clear storage, etc.
  }

  /**
   * Cleanup on shutdown
   */
  destroy(): void {
    if (this.cleanupInterval) {
      clearInterval(this.cleanupInterval);
    }
  }
}

class ReplayAttackError extends Error {
  constructor(message: string) {
    super(message);
    this.name = 'ReplayAttackError';
  }
}

Replay attack detection is critical for security. The implementation above maintains a temporary record of used refresh tokens (hashed for security) and flags any reuse outside the grace period. Production systems should integrate this with security monitoring and alerting systems to detect potential account compromises.

Token Revocation

Token revocation allows users to explicitly invalidate tokens during logout, password changes, or security events. OAuth 2.1 defines a revocation endpoint that accepts tokens and marks them as invalid. Your ChatGPT app should call this endpoint during logout and provide mechanisms for administrators to revoke tokens for compromised accounts.

Here's a production-ready token revocation handler:

// src/auth/token-revocation-handler.ts
export interface RevocationOptions {
  revokeRefreshToken: boolean;
  revokeAccessToken: boolean;
  clearLocalStorage: boolean;
}

export class TokenRevocationHandler {
  private revocationEndpoint: string;
  private clientId: string;
  private clientSecret: string;
  private tokenStorage: TokenStorage;

  constructor(
    revocationEndpoint: string,
    clientId: string,
    clientSecret: string,
    tokenStorage: TokenStorage
  ) {
    this.revocationEndpoint = revocationEndpoint;
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.tokenStorage = tokenStorage;
  }

  /**
   * Revoke tokens during logout
   */
  async revokeTokensOnLogout(
    userId: string,
    options: Partial<RevocationOptions> = {}
  ): Promise<void> {
    const opts: RevocationOptions = {
      revokeRefreshToken: options.revokeRefreshToken ?? true,
      revokeAccessToken: options.revokeAccessToken ?? false,
      clearLocalStorage: options.clearLocalStorage ?? true
    };

    const tokens = await this.tokenStorage.getTokens(userId);

    if (!tokens) {
      console.warn(`No tokens found for user ${userId} during logout`);
      return;
    }

    const revocationPromises: Promise<void>[] = [];

    // Revoke refresh token (most important)
    if (opts.revokeRefreshToken && tokens.refresh_token) {
      revocationPromises.push(
        this.revokeToken(tokens.refresh_token, 'refresh_token')
      );
    }

    // Optionally revoke access token (less critical due to short lifetime)
    if (opts.revokeAccessToken && tokens.access_token) {
      revocationPromises.push(
        this.revokeToken(tokens.access_token, 'access_token')
      );
    }

    // Execute revocations in parallel
    await Promise.allSettled(revocationPromises);

    // Clear local storage
    if (opts.clearLocalStorage) {
      await this.tokenStorage.clearTokens(userId);
    }

    console.log(`Tokens revoked for user ${userId} during logout`);
  }

  /**
   * Revoke specific token
   */
  private async revokeToken(
    token: string,
    tokenTypeHint: 'access_token' | 'refresh_token'
  ): Promise<void> {
    const body = new URLSearchParams({
      token,
      token_type_hint: tokenTypeHint,
      client_id: this.clientId,
      client_secret: this.clientSecret
    });

    try {
      const response = await fetch(this.revocationEndpoint, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        },
        body: body.toString()
      });

      // RFC 7009: Revocation endpoint returns 200 for both success and invalid token
      if (!response.ok) {
        console.error(
          `Token revocation failed with HTTP ${response.status}`
        );
      }
    } catch (error) {
      console.error(`Token revocation request failed:`, error);
      // Don't throw - revocation is best-effort
    }
  }

  /**
   * Administrative revocation (e.g., account compromise)
   */
  async adminRevokeAllUserTokens(userId: string): Promise<void> {
    console.log(`Admin revocation initiated for user ${userId}`);

    await this.revokeTokensOnLogout(userId, {
      revokeRefreshToken: true,
      revokeAccessToken: true,
      clearLocalStorage: true
    });

    // Additional steps for admin revocation
    // - Update database flag to reject future refresh attempts
    // - Send security notification to user
    // - Log security event
  }
}

The revocation handler implements best-effort revocation, continuing even if the revocation endpoint is unavailable. This ensures local token cleanup always occurs, preventing reuse even if server-side revocation fails. Production systems should log revocation failures for security monitoring.

Error Handling and Recovery

Robust error handling ensures your ChatGPT app gracefully handles refresh failures, network errors, and authorization server issues. Recovery strategies include retry with exponential backoff, fallback to re-authentication, and user-friendly error messages that don't expose security details.

Here's a production-ready error recovery system:

// src/auth/token-error-handler.ts
export interface ErrorRecoveryStrategy {
  maxRetries: number;
  baseRetryDelayMs: number;
  fallbackToReauth: boolean;
  notifyUser: boolean;
}

export class TokenErrorHandler {
  private strategy: ErrorRecoveryStrategy;

  constructor(strategy: Partial<ErrorRecoveryStrategy> = {}) {
    this.strategy = {
      maxRetries: strategy.maxRetries || 3,
      baseRetryDelayMs: strategy.baseRetryDelayMs || 1000,
      fallbackToReauth: strategy.fallbackToReauth ?? true,
      notifyUser: strategy.notifyUser ?? true
    };
  }

  /**
   * Handle token refresh errors with recovery
   */
  async handleRefreshError(
    error: Error,
    userId: string,
    attemptNumber: number
  ): Promise<'retry' | 'reauth' | 'fail'> {
    console.error(
      `Token refresh error for user ${userId} (attempt ${attemptNumber}):`,
      error
    );

    // Categorize error
    const errorCategory = this.categorizeError(error);

    switch (errorCategory) {
      case 'invalid_grant':
        // Refresh token expired or revoked - must re-authenticate
        console.log(`Invalid grant for user ${userId}, requiring re-authentication`);
        return this.triggerReauthentication(userId);

      case 'network_error':
        // Transient network issue - retry
        if (attemptNumber < this.strategy.maxRetries) {
          const delay = this.calculateBackoff(attemptNumber);
          console.log(`Network error, retrying in ${delay}ms...`);
          await this.delay(delay);
          return 'retry';
        }
        return this.handleMaxRetriesExceeded(userId);

      case 'server_error':
        // Authorization server issue - retry with backoff
        if (attemptNumber < this.strategy.maxRetries) {
          const delay = this.calculateBackoff(attemptNumber);
          console.log(`Server error, retrying in ${delay}ms...`);
          await this.delay(delay);
          return 'retry';
        }
        return this.handleMaxRetriesExceeded(userId);

      case 'configuration_error':
        // Client misconfiguration - don't retry
        console.error(`Configuration error for user ${userId}, manual intervention required`);
        return 'fail';

      default:
        // Unknown error - apply default retry logic
        if (attemptNumber < this.strategy.maxRetries) {
          return 'retry';
        }
        return this.handleMaxRetriesExceeded(userId);
    }
  }

  private categorizeError(error: Error): string {
    if (error instanceof TokenRefreshError) {
      switch (error.errorCode) {
        case 'invalid_grant':
          return 'invalid_grant';
        case 'invalid_client':
          return 'configuration_error';
        case 'server_error':
        case 'temporarily_unavailable':
          return 'server_error';
        default:
          return 'unknown';
      }
    }

    // Network errors
    if (error.message.includes('fetch') || error.message.includes('network')) {
      return 'network_error';
    }

    return 'unknown';
  }

  private calculateBackoff(attemptNumber: number): number {
    // Exponential backoff with jitter
    const exponentialDelay = this.strategy.baseRetryDelayMs * Math.pow(2, attemptNumber - 1);
    const jitter = Math.random() * 0.3 * exponentialDelay; // ±30% jitter
    return Math.min(exponentialDelay + jitter, 30000); // Cap at 30 seconds
  }

  private async triggerReauthentication(userId: string): Promise<'reauth'> {
    if (this.strategy.fallbackToReauth) {
      console.log(`Triggering re-authentication for user ${userId}`);

      if (this.strategy.notifyUser) {
        // Notify user they need to re-authenticate
        // Implementation depends on your notification system
        this.notifyUser(userId, 'Your session has expired. Please log in again.');
      }

      return 'reauth';
    }

    return 'fail' as any;
  }

  private async handleMaxRetriesExceeded(userId: string): Promise<'reauth' | 'fail'> {
    console.error(`Max retries exceeded for user ${userId}`);

    if (this.strategy.fallbackToReauth) {
      if (this.strategy.notifyUser) {
        this.notifyUser(
          userId,
          'Unable to refresh your session. Please log in again.'
        );
      }
      return 'reauth';
    }

    return 'fail';
  }

  private notifyUser(userId: string, message: string): void {
    // Implementation depends on your notification system
    console.log(`Notification for user ${userId}: ${message}`);
  }

  private delay(ms: number): Promise<void> {
    return new Promise(resolve => setTimeout(resolve, ms));
  }
}

The error handler implements intelligent retry strategies based on error type. Network errors and server errors trigger exponential backoff retries, while invalid grant errors immediately trigger re-authentication. This ensures your ChatGPT app handles transient failures gracefully without annoying users with unnecessary re-authentication prompts.

Conclusion

Implementing secure token refresh strategies is essential for ChatGPT apps that maintain authenticated sessions. Production-ready implementations require refresh token handlers with retry logic, silent refresh managers for proactive token renewal, encrypted token storage with rotation policies, revocation handlers for logout flows, and comprehensive error recovery strategies.

The code examples in this guide provide complete, production-ready implementations you can integrate into your ChatGPT app today. Key takeaways include: always implement token rotation to prevent replay attacks, use silent refresh to maintain sessions without user interaction, encrypt tokens at rest with AES-256-GCM, implement robust error handling with exponential backoff, and revoke tokens on logout and security events.

Build ChatGPT Apps with Secure OAuth 2.1 Implementation

MakeAIHQ provides a no-code platform for building ChatGPT apps with production-grade OAuth 2.1 security built-in. Our platform automatically generates secure MCP servers with token refresh, rotation policies, encrypted storage, and error recovery—no security expertise required. Deploy to the ChatGPT App Store in 48 hours with enterprise-grade authentication that passes OpenAI's security review on first submission.

Start Building Your ChatGPT App →


Related Resources

External References