OAuth Scope Management for ChatGPT Apps

OAuth scopes are the cornerstone of secure API authorization, defining precisely what actions an authenticated user can perform within your ChatGPT app. Proper scope management implements the principle of least privilege—granting users and applications only the minimum permissions necessary to accomplish their tasks. This security-first approach minimizes the attack surface by limiting the damage that compromised credentials can inflict.

Effective scope design balances security with user experience. Too granular, and users face consent fatigue from excessive permission requests. Too coarse, and applications gain unnecessary access to sensitive resources. For ChatGPT apps integrating with the OpenAI Apps SDK, scope management becomes particularly critical because your application bridges conversational AI with external services and data sources.

This guide explores production-ready strategies for OAuth scope design, dynamic authorization, runtime validation, and scope upgrades. You'll learn how to implement granular permission systems that protect user data while maintaining the seamless conversational experience that makes ChatGPT apps powerful. Whether you're building a fitness tracker, real estate CRM, or restaurant reservation system, mastering OAuth scopes ensures your app meets enterprise security standards while delivering exceptional functionality.

Modern scope management goes beyond static permission lists. It encompasses incremental authorization (requesting permissions only when needed), runtime scope validation (verifying every API request), and transparent consent management (communicating clearly what data your app accesses). Let's dive into the architecture that makes this possible.

Scope Design Principles

Designing an effective scope hierarchy requires understanding your application's resource model and user workflows. Start by mapping every API endpoint to the minimum permissions required to execute it. A well-designed scope system uses hierarchical naming conventions that make permissions self-documenting and easy to audit.

Granular vs. Coarse Scopes

The granularity spectrum ranges from ultra-specific scopes (workouts:read:own for reading only your own workout data) to broad scopes (workouts:admin for all workout operations). Find the sweet spot by analyzing user roles and common permission patterns:

Granular scopes (recommended for ChatGPT apps):

  • profile:read - Read basic profile information
  • profile:write - Update profile fields
  • workouts:read - View workout data
  • workouts:write - Create/update workouts
  • appointments:read - View appointment schedules
  • appointments:manage - Book/cancel appointments
  • billing:read - View billing history
  • billing:manage - Update payment methods

Coarse scopes (avoid for production apps):

  • admin - Full administrative access (too broad)
  • data:all - All data access (violates least privilege)
  • user:everything - Every user operation (no granularity)

Scope Naming Conventions

Adopt a consistent naming pattern that communicates resource, action, and optionally, ownership:

<resource>:<action>[:<ownership>]

Examples:
- messages:read:own (read only your messages)
- calendar:write:shared (write to shared calendars)
- reports:delete:admin (admin-level deletion)

This convention makes it trivial to parse scopes programmatically and generate permission matrices for security audits.

Implementing Scope Definitions

Here's a production-ready TypeScript scope registry that serves as the single source of truth for your application's permission model:

/**
 * OAuth Scope Registry
 * Central definition of all application scopes with metadata
 */

export interface ScopeDefinition {
  scope: string;
  resource: string;
  action: 'read' | 'write' | 'delete' | 'manage' | 'admin';
  ownership?: 'own' | 'shared' | 'all';
  description: string;
  category: 'profile' | 'workouts' | 'appointments' | 'billing' | 'admin';
  isPrivileged: boolean;
  requiresReauth?: boolean;
  impliedScopes?: string[];
}

export class ScopeRegistry {
  private static scopes: Map<string, ScopeDefinition> = new Map([
    // Profile scopes
    ['profile:read', {
      scope: 'profile:read',
      resource: 'profile',
      action: 'read',
      ownership: 'own',
      description: 'Read basic profile information (name, email, avatar)',
      category: 'profile',
      isPrivileged: false
    }],
    ['profile:write', {
      scope: 'profile:write',
      resource: 'profile',
      action: 'write',
      ownership: 'own',
      description: 'Update profile fields (name, bio, preferences)',
      category: 'profile',
      isPrivileged: false,
      impliedScopes: ['profile:read']
    }],

    // Workout scopes
    ['workouts:read', {
      scope: 'workouts:read',
      resource: 'workouts',
      action: 'read',
      ownership: 'own',
      description: 'View workout history and exercise data',
      category: 'workouts',
      isPrivileged: false
    }],
    ['workouts:write', {
      scope: 'workouts:write',
      resource: 'workouts',
      action: 'write',
      ownership: 'own',
      description: 'Create and update workout sessions',
      category: 'workouts',
      isPrivileged: false,
      impliedScopes: ['workouts:read']
    }],
    ['workouts:delete', {
      scope: 'workouts:delete',
      resource: 'workouts',
      action: 'delete',
      ownership: 'own',
      description: 'Delete workout records',
      category: 'workouts',
      isPrivileged: true,
      requiresReauth: true
    }],

    // Appointment scopes
    ['appointments:read', {
      scope: 'appointments:read',
      resource: 'appointments',
      action: 'read',
      ownership: 'own',
      description: 'View scheduled appointments',
      category: 'appointments',
      isPrivileged: false
    }],
    ['appointments:manage', {
      scope: 'appointments:manage',
      resource: 'appointments',
      action: 'manage',
      ownership: 'own',
      description: 'Book, reschedule, and cancel appointments',
      category: 'appointments',
      isPrivileged: false,
      impliedScopes: ['appointments:read']
    }],

    // Billing scopes
    ['billing:read', {
      scope: 'billing:read',
      resource: 'billing',
      action: 'read',
      ownership: 'own',
      description: 'View billing history and invoices',
      category: 'billing',
      isPrivileged: true
    }],
    ['billing:manage', {
      scope: 'billing:manage',
      resource: 'billing',
      action: 'manage',
      ownership: 'own',
      description: 'Update payment methods and billing details',
      category: 'billing',
      isPrivileged: true,
      requiresReauth: true,
      impliedScopes: ['billing:read']
    }],

    // Admin scopes
    ['admin:users', {
      scope: 'admin:users',
      resource: 'users',
      action: 'admin',
      ownership: 'all',
      description: 'Administer user accounts (view, modify, delete)',
      category: 'admin',
      isPrivileged: true,
      requiresReauth: true
    }],
    ['admin:reports', {
      scope: 'admin:reports',
      resource: 'reports',
      action: 'admin',
      ownership: 'all',
      description: 'Generate administrative reports and analytics',
      category: 'admin',
      isPrivileged: true
    }]
  ]);

  /**
   * Get scope definition by scope string
   */
  static get(scope: string): ScopeDefinition | undefined {
    return this.scopes.get(scope);
  }

  /**
   * Get all defined scopes
   */
  static getAll(): ScopeDefinition[] {
    return Array.from(this.scopes.values());
  }

  /**
   * Get scopes by category
   */
  static getByCategory(category: ScopeDefinition['category']): ScopeDefinition[] {
    return this.getAll().filter(s => s.category === category);
  }

  /**
   * Get all implied scopes for a given scope (recursive)
   */
  static getImpliedScopes(scope: string): string[] {
    const definition = this.get(scope);
    if (!definition?.impliedScopes) return [];

    const implied = new Set<string>(definition.impliedScopes);

    // Recursively get implied scopes
    for (const impliedScope of definition.impliedScopes) {
      const nested = this.getImpliedScopes(impliedScope);
      nested.forEach(s => implied.add(s));
    }

    return Array.from(implied);
  }

  /**
   * Expand scopes to include all implied scopes
   */
  static expandScopes(scopes: string[]): string[] {
    const expanded = new Set<string>(scopes);

    for (const scope of scopes) {
      const implied = this.getImpliedScopes(scope);
      implied.forEach(s => expanded.add(s));
    }

    return Array.from(expanded);
  }

  /**
   * Validate scope string format
   */
  static isValid(scope: string): boolean {
    return this.scopes.has(scope);
  }

  /**
   * Get privileged scopes from list
   */
  static getPrivilegedScopes(scopes: string[]): string[] {
    return scopes.filter(s => {
      const def = this.get(s);
      return def?.isPrivileged === true;
    });
  }

  /**
   * Check if re-authentication is required for scopes
   */
  static requiresReauth(scopes: string[]): boolean {
    return scopes.some(s => {
      const def = this.get(s);
      return def?.requiresReauth === true;
    });
  }
}

This registry enables centralized scope management with strong typing, scope expansion (automatically including implied permissions), and privileged scope identification for heightened security checks.

Dynamic Scope Requests

Static scope requests during initial authorization create poor user experiences and security vulnerabilities. Users confronted with long permission lists during signup often abandon the flow. Worse, applications that request broad permissions "just in case" violate the principle of least privilege.

Dynamic scope management solves this through incremental authorization—requesting permissions only when features requiring them are accessed. A fitness app might request profile:read during signup, then request workouts:write only when the user first attempts to log a workout.

Implementing Incremental Authorization

Here's a production-ready incremental authorization system that requests additional scopes at runtime:

/**
 * Incremental Authorization Manager
 * Handles dynamic scope requests with caching and consent tracking
 */

import { ScopeRegistry } from './scope-registry';

interface AuthorizationState {
  userId: string;
  grantedScopes: string[];
  pendingScopes: string[];
  deniedScopes: Map<string, Date>;
  lastAuthTime: Date;
}

export class IncrementalAuthManager {
  private authState: Map<string, AuthorizationState> = new Map();

  constructor(
    private tokenEndpoint: string,
    private clientId: string,
    private redirectUri: string
  ) {}

  /**
   * Check if user has required scopes
   */
  async hasScopes(userId: string, requiredScopes: string[]): Promise<boolean> {
    const state = this.authState.get(userId);
    if (!state) return false;

    // Expand required scopes to include implied scopes
    const expandedRequired = ScopeRegistry.expandScopes(requiredScopes);
    const grantedSet = new Set(state.grantedScopes);

    return expandedRequired.every(scope => grantedSet.has(scope));
  }

  /**
   * Request additional scopes with user consent
   */
  async requestScopes(
    userId: string,
    newScopes: string[],
    context?: {
      feature: string;
      reason: string;
    }
  ): Promise<{
    success: boolean;
    grantedScopes: string[];
    authorizationUrl?: string;
  }> {
    const state = this.authState.get(userId);
    if (!state) {
      throw new Error('User not authenticated');
    }

    // Validate requested scopes
    const invalidScopes = newScopes.filter(s => !ScopeRegistry.isValid(s));
    if (invalidScopes.length > 0) {
      throw new Error(`Invalid scopes: ${invalidScopes.join(', ')}`);
    }

    // Filter out already granted scopes
    const grantedSet = new Set(state.grantedScopes);
    const scopesToRequest = newScopes.filter(s => !grantedSet.has(s));

    if (scopesToRequest.length === 0) {
      return { success: true, grantedScopes: state.grantedScopes };
    }

    // Check for recently denied scopes (respect user decisions)
    const now = new Date();
    const deniedRecently = scopesToRequest.filter(scope => {
      const deniedAt = state.deniedScopes.get(scope);
      if (!deniedAt) return false;

      // Don't re-request denied scopes within 7 days
      const daysSinceDenial = (now.getTime() - deniedAt.getTime()) / (1000 * 60 * 60 * 24);
      return daysSinceDenial < 7;
    });

    if (deniedRecently.length > 0) {
      console.warn(`Scopes denied recently: ${deniedRecently.join(', ')}`);
      return { success: false, grantedScopes: state.grantedScopes };
    }

    // Build authorization URL for incremental consent
    const authUrl = this.buildIncrementalAuthUrl(userId, scopesToRequest, context);

    // Mark scopes as pending
    state.pendingScopes = scopesToRequest;
    this.authState.set(userId, state);

    return {
      success: false,
      grantedScopes: state.grantedScopes,
      authorizationUrl: authUrl
    };
  }

  /**
   * Build OAuth authorization URL for incremental scopes
   */
  private buildIncrementalAuthUrl(
    userId: string,
    scopes: string[],
    context?: { feature: string; reason: string }
  ): string {
    const params = new URLSearchParams({
      response_type: 'code',
      client_id: this.clientId,
      redirect_uri: this.redirectUri,
      scope: scopes.join(' '),
      state: this.generateState(userId, context),
      prompt: 'consent', // Force consent screen for new scopes
      access_type: 'offline' // Request refresh token
    });

    return `${this.tokenEndpoint}?${params.toString()}`;
  }

  /**
   * Generate state parameter with context
   */
  private generateState(
    userId: string,
    context?: { feature: string; reason: string }
  ): string {
    const stateObj = {
      userId,
      timestamp: Date.now(),
      context,
      nonce: crypto.randomUUID()
    };

    return Buffer.from(JSON.stringify(stateObj)).toString('base64url');
  }

  /**
   * Process authorization callback
   */
  async processCallback(
    code: string,
    state: string
  ): Promise<{
    userId: string;
    grantedScopes: string[];
    newScopes: string[];
  }> {
    // Decode state
    const stateObj = JSON.parse(
      Buffer.from(state, 'base64url').toString('utf-8')
    );

    const { userId } = stateObj;
    const authState = this.authState.get(userId);
    if (!authState) {
      throw new Error('Invalid auth state');
    }

    // Exchange code for token
    const tokenResponse = await this.exchangeCodeForToken(code);
    const newScopes = tokenResponse.scope.split(' ');

    // Update granted scopes
    const previousScopes = new Set(authState.grantedScopes);
    const grantedSet = new Set([...authState.grantedScopes, ...newScopes]);
    authState.grantedScopes = Array.from(grantedSet);
    authState.pendingScopes = [];
    authState.lastAuthTime = new Date();

    // Clear denied scopes that were granted
    newScopes.forEach(scope => authState.deniedScopes.delete(scope));

    this.authState.set(userId, authState);

    // Determine truly new scopes
    const actuallyNewScopes = newScopes.filter(s => !previousScopes.has(s));

    return {
      userId,
      grantedScopes: authState.grantedScopes,
      newScopes: actuallyNewScopes
    };
  }

  /**
   * Record scope denial (user rejected consent)
   */
  recordScopeDenial(userId: string, deniedScopes: string[]): void {
    const state = this.authState.get(userId);
    if (!state) return;

    const now = new Date();
    deniedScopes.forEach(scope => {
      state.deniedScopes.set(scope, now);
    });

    state.pendingScopes = [];
    this.authState.set(userId, state);
  }

  /**
   * Exchange authorization code for access token
   */
  private async exchangeCodeForToken(code: string): Promise<{
    access_token: string;
    refresh_token?: string;
    scope: string;
    expires_in: number;
  }> {
    // Implementation depends on your OAuth provider
    // This is a placeholder showing the expected return type
    throw new Error('Implement token exchange');
  }
}

This manager tracks granted, pending, and denied scopes with time-based retry logic to avoid pestering users with repeated permission requests.

Scope Validation & Enforcement

Runtime scope validation ensures every API request is authorized based on the requesting user's granted scopes. Implement middleware that intercepts requests, extracts scopes from the access token, and verifies permissions before executing business logic.

Token Introspection & Validation

Here's a production-ready scope validator middleware for Express applications:

/**
 * OAuth Scope Validator Middleware
 * Validates access tokens and enforces scope requirements
 */

import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { ScopeRegistry, ScopeDefinition } from './scope-registry';

interface TokenPayload {
  sub: string; // User ID
  scope: string; // Space-separated scopes
  client_id: string;
  exp: number;
  iat: number;
  aud: string;
  iss: string;
}

export interface AuthenticatedRequest extends Request {
  user?: {
    userId: string;
    scopes: string[];
    clientId: string;
  };
}

export class ScopeValidator {
  constructor(
    private jwtSecret: string,
    private issuer: string,
    private audience: string
  ) {}

  /**
   * Middleware: Extract and validate access token
   */
  authenticate() {
    return async (
      req: AuthenticatedRequest,
      res: Response,
      next: NextFunction
    ): Promise<void> => {
      try {
        const token = this.extractToken(req);
        if (!token) {
          res.status(401).json({
            error: 'unauthorized',
            error_description: 'Missing access token'
          });
          return;
        }

        // Verify JWT signature and claims
        const payload = jwt.verify(token, this.jwtSecret, {
          issuer: this.issuer,
          audience: this.audience
        }) as TokenPayload;

        // Parse scopes from token
        const scopes = payload.scope ? payload.scope.split(' ') : [];

        // Attach user info to request
        req.user = {
          userId: payload.sub,
          scopes: ScopeRegistry.expandScopes(scopes), // Include implied scopes
          clientId: payload.client_id
        };

        next();
      } catch (error) {
        if (error instanceof jwt.TokenExpiredError) {
          res.status(401).json({
            error: 'token_expired',
            error_description: 'Access token has expired'
          });
        } else if (error instanceof jwt.JsonWebTokenError) {
          res.status(401).json({
            error: 'invalid_token',
            error_description: 'Invalid access token'
          });
        } else {
          res.status(500).json({
            error: 'server_error',
            error_description: 'Token validation failed'
          });
        }
      }
    };
  }

  /**
   * Middleware: Require specific scopes
   */
  requireScopes(...requiredScopes: string[]) {
    return (
      req: AuthenticatedRequest,
      res: Response,
      next: NextFunction
    ): void => {
      if (!req.user) {
        res.status(401).json({
          error: 'unauthorized',
          error_description: 'Authentication required'
        });
        return;
      }

      const userScopes = new Set(req.user.scopes);
      const missingScopes = requiredScopes.filter(scope => !userScopes.has(scope));

      if (missingScopes.length > 0) {
        res.status(403).json({
          error: 'insufficient_scope',
          error_description: 'Token does not have required scopes',
          required_scopes: requiredScopes,
          missing_scopes: missingScopes
        });
        return;
      }

      next();
    };
  }

  /**
   * Middleware: Require ANY of the specified scopes
   */
  requireAnyScope(...acceptableScopes: string[]) {
    return (
      req: AuthenticatedRequest,
      res: Response,
      next: NextFunction
    ): void => {
      if (!req.user) {
        res.status(401).json({
          error: 'unauthorized',
          error_description: 'Authentication required'
        });
        return;
      }

      const userScopes = new Set(req.user.scopes);
      const hasAnyScope = acceptableScopes.some(scope => userScopes.has(scope));

      if (!hasAnyScope) {
        res.status(403).json({
          error: 'insufficient_scope',
          error_description: 'Token does not have any required scope',
          acceptable_scopes: acceptableScopes
        });
        return;
      }

      next();
    };
  }

  /**
   * Extract Bearer token from Authorization header
   */
  private extractToken(req: Request): string | null {
    const authHeader = req.headers.authorization;
    if (!authHeader) return null;

    const [scheme, token] = authHeader.split(' ');
    if (scheme.toLowerCase() !== 'bearer') return null;

    return token;
  }

  /**
   * Check if token has privileged scopes
   */
  static hasPrivilegedScopes(scopes: string[]): boolean {
    return ScopeRegistry.getPrivilegedScopes(scopes).length > 0;
  }

  /**
   * Check if operation requires re-authentication
   */
  static requiresReauth(scopes: string[]): boolean {
    return ScopeRegistry.requiresReauth(scopes);
  }
}

// Usage example in Express routes:
/*
import express from 'express';

const app = express();
const validator = new ScopeValidator(
  process.env.JWT_SECRET!,
  'https://auth.example.com',
  'chatgpt-fitness-app'
);

// Public endpoint (no authentication)
app.get('/api/public/templates', async (req, res) => {
  // ...
});

// Protected endpoint (authentication required)
app.get('/api/profile',
  validator.authenticate(),
  async (req: AuthenticatedRequest, res) => {
    const userId = req.user!.userId;
    // ...
  }
);

// Scope-protected endpoint (specific permission required)
app.post('/api/workouts',
  validator.authenticate(),
  validator.requireScopes('workouts:write'),
  async (req: AuthenticatedRequest, res) => {
    // User has workouts:write scope
  }
);

// Multiple scope options (ANY of these scopes)
app.get('/api/appointments',
  validator.authenticate(),
  validator.requireAnyScope('appointments:read', 'admin:users'),
  async (req: AuthenticatedRequest, res) => {
    // User has appointments:read OR admin:users
  }
);

// Privileged operation (requires re-auth)
app.delete('/api/account',
  validator.authenticate(),
  validator.requireScopes('account:delete'),
  async (req: AuthenticatedRequest, res) => {
    if (ScopeValidator.requiresReauth(['account:delete'])) {
      // Verify recent authentication (e.g., within last 5 minutes)
      // If not recent, return 403 with re-auth requirement
    }
    // ...
  }
);
*/

This middleware enforces scope requirements at the route level with flexible AND/OR logic, automatic scope expansion, and privileged operation detection.

Scope Upgrades & Re-Authorization

Applications evolve, adding features that require permissions beyond the initially requested scopes. Scope upgrades let you request additional permissions without disrupting existing functionality. Handle upgrades gracefully with clear communication about why new permissions are needed.

Implementing Scope Upgrade Flows

Here's a scope upgrade manager that detects missing permissions and guides users through re-authorization:

/**
 * Scope Upgrade Manager
 * Handles permission upgrades with user communication
 */

import { ScopeRegistry } from './scope-registry';
import { IncrementalAuthManager } from './incremental-auth';

export interface UpgradeReason {
  feature: string;
  description: string;
  requiredScopes: string[];
  benefits: string[];
}

export class ScopeUpgradeManager {
  constructor(
    private authManager: IncrementalAuthManager
  ) {}

  /**
   * Check if feature is available with current scopes
   */
  async checkFeatureAvailability(
    userId: string,
    feature: string,
    requiredScopes: string[]
  ): Promise<{
    available: boolean;
    missingScopes: string[];
    upgradeReason?: UpgradeReason;
  }> {
    const hasScopes = await this.authManager.hasScopes(userId, requiredScopes);

    if (hasScopes) {
      return { available: true, missingScopes: [] };
    }

    // Determine missing scopes
    const userState = await this.authManager.hasScopes(userId, []);
    const missingScopes = requiredScopes.filter(scope => !userState);

    const upgradeReason = this.buildUpgradeReason(feature, missingScopes);

    return {
      available: false,
      missingScopes,
      upgradeReason
    };
  }

  /**
   * Build user-friendly upgrade reason
   */
  private buildUpgradeReason(
    feature: string,
    missingScopes: string[]
  ): UpgradeReason {
    const scopeDescriptions = missingScopes
      .map(s => ScopeRegistry.get(s))
      .filter((def): def is ScopeDefinition => def !== undefined)
      .map(def => def.description);

    return {
      feature,
      description: `To use ${feature}, we need permission to:`,
      requiredScopes: missingScopes,
      benefits: scopeDescriptions
    };
  }

  /**
   * Request scope upgrade with context
   */
  async requestUpgrade(
    userId: string,
    upgradeReason: UpgradeReason
  ): Promise<{
    success: boolean;
    authorizationUrl?: string;
  }> {
    const result = await this.authManager.requestScopes(
      userId,
      upgradeReason.requiredScopes,
      {
        feature: upgradeReason.feature,
        reason: upgradeReason.description
      }
    );

    return {
      success: result.success,
      authorizationUrl: result.authorizationUrl
    };
  }

  /**
   * Generate user-facing upgrade prompt
   */
  generateUpgradePrompt(reason: UpgradeReason): {
    title: string;
    message: string;
    permissions: string[];
    ctaText: string;
  } {
    return {
      title: `Enable ${reason.feature}`,
      message: reason.description,
      permissions: reason.benefits,
      ctaText: 'Grant Permissions'
    };
  }
}

This manager builds user-friendly upgrade prompts that explain exactly why new permissions are needed and what benefits they unlock.

Admin & Service Scopes

Privileged scopes grant elevated permissions for administrative tasks and service-to-service communication. Implement additional safeguards for admin scopes: require re-authentication, log all privileged operations, and limit scope lifetime for sensitive actions.

Admin Scope Enforcement

Here's an admin scope enforcer with enhanced security controls:

/**
 * Admin Scope Enforcer
 * Enhanced security for privileged operations
 */

import { AuthenticatedRequest } from './scope-validator';
import { Response, NextFunction } from 'express';

interface AdminAuditLog {
  userId: string;
  action: string;
  scope: string;
  timestamp: Date;
  ipAddress: string;
  userAgent: string;
  resourceId?: string;
  outcome: 'success' | 'failure';
  reason?: string;
}

export class AdminScopeEnforcer {
  private auditLogs: AdminAuditLog[] = [];
  private recentAuthCache: Map<string, Date> = new Map();

  /**
   * Require recent authentication for privileged operations
   */
  requireRecentAuth(maxAgeMinutes: number = 5) {
    return (
      req: AuthenticatedRequest,
      res: Response,
      next: NextFunction
    ): void => {
      if (!req.user) {
        res.status(401).json({ error: 'unauthorized' });
        return;
      }

      const lastAuth = this.recentAuthCache.get(req.user.userId);
      const now = new Date();

      if (!lastAuth) {
        res.status(403).json({
          error: 'reauth_required',
          error_description: 'Recent authentication required for this operation'
        });
        return;
      }

      const minutesSinceAuth = (now.getTime() - lastAuth.getTime()) / 60000;
      if (minutesSinceAuth > maxAgeMinutes) {
        res.status(403).json({
          error: 'reauth_required',
          error_description: `Re-authentication required (last auth: ${minutesSinceAuth.toFixed(0)} minutes ago)`
        });
        return;
      }

      next();
    };
  }

  /**
   * Record authentication timestamp
   */
  recordAuthentication(userId: string): void {
    this.recentAuthCache.set(userId, new Date());
  }

  /**
   * Audit privileged operation
   */
  auditAdminAction(
    userId: string,
    action: string,
    scope: string,
    req: AuthenticatedRequest,
    outcome: 'success' | 'failure',
    reason?: string,
    resourceId?: string
  ): void {
    const log: AdminAuditLog = {
      userId,
      action,
      scope,
      timestamp: new Date(),
      ipAddress: req.ip || 'unknown',
      userAgent: req.headers['user-agent'] || 'unknown',
      outcome,
      reason,
      resourceId
    };

    this.auditLogs.push(log);

    // In production, persist to database and alerting system
    console.log('[ADMIN AUDIT]', JSON.stringify(log));
  }

  /**
   * Get audit logs for user
   */
  getAuditLogs(userId?: string): AdminAuditLog[] {
    if (userId) {
      return this.auditLogs.filter(log => log.userId === userId);
    }
    return this.auditLogs;
  }
}

This enforcer adds time-based re-authentication requirements and comprehensive audit logging for compliance with security standards like SOC 2 and ISO 27001.

Scope Consent UI Component

Transparent consent interfaces build user trust. Here's a React component that presents scope requests with clear explanations:

/**
 * Scope Consent UI Component
 * User-friendly permission request interface
 */

import React, { useState } from 'react';
import { ScopeRegistry, ScopeDefinition } from './scope-registry';

interface ScopeConsentProps {
  requestedScopes: string[];
  appName: string;
  onApprove: () => void;
  onDeny: () => void;
  context?: {
    feature: string;
    reason: string;
  };
}

export const ScopeConsentDialog: React.FC<ScopeConsentProps> = ({
  requestedScopes,
  appName,
  onApprove,
  onDeny,
  context
}) => {
  const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set());

  // Group scopes by category
  const scopesByCategory = requestedScopes.reduce((acc, scopeStr) => {
    const scope = ScopeRegistry.get(scopeStr);
    if (!scope) return acc;

    if (!acc[scope.category]) {
      acc[scope.category] = [];
    }
    acc[scope.category].push(scope);
    return acc;
  }, {} as Record<string, ScopeDefinition[]>);

  const toggleCategory = (category: string) => {
    const newExpanded = new Set(expandedCategories);
    if (newExpanded.has(category)) {
      newExpanded.delete(category);
    } else {
      newExpanded.add(category);
    }
    setExpandedCategories(newExpanded);
  };

  const categoryLabels: Record<string, string> = {
    profile: 'Profile Information',
    workouts: 'Workout Data',
    appointments: 'Appointments',
    billing: 'Billing & Payments',
    admin: 'Administrative Access'
  };

  const privilegedScopes = ScopeRegistry.getPrivilegedScopes(requestedScopes);
  const hasPrivilegedScopes = privilegedScopes.length > 0;

  return (
    <div className="scope-consent-dialog">
      <div className="consent-header">
        <h2>{context?.feature || `${appName} Permissions`}</h2>
        {context?.reason && (
          <p className="consent-context">{context.reason}</p>
        )}
      </div>

      <div className="consent-body">
        <p className="consent-intro">
          {appName} is requesting the following permissions:
        </p>

        {hasPrivilegedScopes && (
          <div className="privileged-warning">
            <span className="warning-icon">⚠️</span>
            <strong>Sensitive Permissions:</strong> This request includes access to sensitive data or administrative functions.
          </div>
        )}

        <div className="scope-categories">
          {Object.entries(scopesByCategory).map(([category, scopes]) => {
            const isExpanded = expandedCategories.has(category);
            const hasPrivileged = scopes.some(s => s.isPrivileged);

            return (
              <div key={category} className="scope-category">
                <button
                  className="category-header"
                  onClick={() => toggleCategory(category)}
                >
                  <span className="category-name">
                    {categoryLabels[category] || category}
                    {hasPrivileged && <span className="privileged-badge">Sensitive</span>}
                  </span>
                  <span className="category-count">
                    {scopes.length} {scopes.length === 1 ? 'permission' : 'permissions'}
                  </span>
                  <span className="expand-icon">
                    {isExpanded ? '▼' : '▶'}
                  </span>
                </button>

                {isExpanded && (
                  <ul className="scope-list">
                    {scopes.map(scope => (
                      <li key={scope.scope} className="scope-item">
                        <div className="scope-header">
                          <span className="scope-action">{scope.action}</span>
                          {scope.isPrivileged && (
                            <span className="privileged-icon">🔒</span>
                          )}
                        </div>
                        <p className="scope-description">{scope.description}</p>
                      </li>
                    ))}
                  </ul>
                )}
              </div>
            );
          })}
        </div>

        <div className="consent-footer">
          <p className="consent-note">
            You can revoke these permissions at any time in your account settings.
          </p>
        </div>
      </div>

      <div className="consent-actions">
        <button className="btn-deny" onClick={onDeny}>
          Deny
        </button>
        <button className="btn-approve" onClick={onApprove}>
          Grant Permissions
        </button>
      </div>

      <style jsx>{`
        .scope-consent-dialog {
          max-width: 500px;
          background: white;
          border-radius: 8px;
          box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
          font-family: system-ui, -apple-system, sans-serif;
        }

        .consent-header {
          padding: 24px 24px 16px;
          border-bottom: 1px solid #e5e7eb;
        }

        .consent-header h2 {
          margin: 0 0 8px;
          font-size: 20px;
          font-weight: 600;
        }

        .consent-context {
          margin: 0;
          color: #6b7280;
          font-size: 14px;
        }

        .consent-body {
          padding: 16px 24px;
        }

        .consent-intro {
          margin: 0 0 16px;
          font-size: 14px;
        }

        .privileged-warning {
          padding: 12px;
          background: #fef3c7;
          border: 1px solid #fbbf24;
          border-radius: 6px;
          margin-bottom: 16px;
          display: flex;
          align-items: center;
          gap: 8px;
          font-size: 14px;
        }

        .scope-categories {
          display: flex;
          flex-direction: column;
          gap: 8px;
        }

        .scope-category {
          border: 1px solid #e5e7eb;
          border-radius: 6px;
          overflow: hidden;
        }

        .category-header {
          width: 100%;
          padding: 12px 16px;
          background: #f9fafb;
          border: none;
          display: flex;
          align-items: center;
          gap: 12px;
          cursor: pointer;
          font-size: 14px;
          font-weight: 500;
        }

        .category-header:hover {
          background: #f3f4f6;
        }

        .category-name {
          flex: 1;
          text-align: left;
          display: flex;
          align-items: center;
          gap: 8px;
        }

        .privileged-badge {
          display: inline-block;
          padding: 2px 8px;
          background: #fbbf24;
          color: #92400e;
          border-radius: 10px;
          font-size: 11px;
          font-weight: 600;
        }

        .category-count {
          color: #6b7280;
          font-size: 13px;
        }

        .expand-icon {
          color: #9ca3af;
        }

        .scope-list {
          list-style: none;
          margin: 0;
          padding: 0;
        }

        .scope-item {
          padding: 12px 16px;
          border-top: 1px solid #e5e7eb;
        }

        .scope-header {
          display: flex;
          align-items: center;
          gap: 8px;
          margin-bottom: 4px;
        }

        .scope-action {
          font-weight: 500;
          text-transform: capitalize;
          font-size: 13px;
        }

        .privileged-icon {
          font-size: 14px;
        }

        .scope-description {
          margin: 0;
          color: #6b7280;
          font-size: 13px;
          line-height: 1.5;
        }

        .consent-footer {
          margin-top: 16px;
          padding-top: 16px;
          border-top: 1px solid #e5e7eb;
        }

        .consent-note {
          margin: 0;
          color: #6b7280;
          font-size: 12px;
        }

        .consent-actions {
          padding: 16px 24px;
          border-top: 1px solid #e5e7eb;
          display: flex;
          gap: 12px;
          justify-content: flex-end;
        }

        .btn-deny,
        .btn-approve {
          padding: 10px 20px;
          border: none;
          border-radius: 6px;
          font-size: 14px;
          font-weight: 500;
          cursor: pointer;
          transition: background 0.2s;
        }

        .btn-deny {
          background: #f3f4f6;
          color: #374151;
        }

        .btn-deny:hover {
          background: #e5e7eb;
        }

        .btn-approve {
          background: #2563eb;
          color: white;
        }

        .btn-approve:hover {
          background: #1d4ed8;
        }
      `}</style>
    </div>
  );
};

This component groups permissions by category, highlights sensitive scopes, and provides expandable details so users can make informed decisions.

Conclusion

OAuth scope management is the foundation of secure, user-respecting ChatGPT apps. By implementing granular scopes, incremental authorization, runtime validation, and transparent consent flows, you protect user data while delivering powerful conversational experiences. The production-ready TypeScript examples in this guide provide battle-tested patterns for scope registries, dynamic authorization, validation middleware, and upgrade management.

Start with minimal scopes during initial authorization, then request additional permissions contextually as users explore advanced features. Enforce scopes rigorously at the API layer with token validation and audit logging. Communicate clearly why permissions are needed through well-designed consent interfaces.

For enterprise ChatGPT apps handling sensitive data—fitness records, financial transactions, medical information—proper scope management isn't optional. It's the difference between a secure, compliant application and a data breach waiting to happen.

Ready to build ChatGPT apps with enterprise-grade OAuth security? MakeAIHQ automatically generates production-ready OAuth implementations with best-practice scope management, incremental authorization, and compliance-ready audit logging. From zero to ChatGPT App Store in 48 hours—no coding required. Start building today.


Related Resources:

  • OAuth 2.1 Security Implementation Guide - Complete OAuth 2.1 architecture
  • OAuth Token Refresh Strategies for ChatGPT Apps - Token lifecycle management
  • OAuth PKCE Implementation for ChatGPT Apps - Authorization code flow security
  • API Security Best Practices for ChatGPT Apps - Comprehensive API security

External References: