OAuth 2.1 for ChatGPT Apps: Complete Authentication Guide

When you're building ChatGPT apps that need to access external services—from Mindbody fitness management systems to restaurant reservation platforms—OAuth 2.1 with PKCE (S256) is not optional. It's the required authentication standard that protects user data and ensures OpenAI approval.

In this comprehensive guide, you'll master OAuth 2.1 authentication from first principles through production deployment. We'll cover PKCE implementation, protected resource metadata, token validation, and the security vulnerabilities that cause rejection from OpenAI's review process.

By the end, you'll understand why OAuth 2.1 is critical, how to implement it correctly, and how to avoid the 12 most common authentication mistakes that cause ChatGPT apps to fail OpenAI's approval checklist.


Table of Contents

  1. Why OAuth 2.1 Matters for ChatGPT Apps
  2. OAuth 2.0 vs OAuth 2.1: What Changed
  3. PKCE (S256): The Security Foundation
  4. Protected Resource Metadata Best Practices
  5. Authorization Flow Deep Dive
  6. Access Token Validation Checklist
  7. Common Security Vulnerabilities
  8. OAuth 2.1 Implementation Tutorial
  9. Testing Your OAuth Implementation
  10. OpenAI Approval Compliance Checklist
  11. Real-World Case Studies

Why OAuth 2.1 Matters for ChatGPT Apps

The Authentication Challenge for ChatGPT Apps

ChatGPT apps operate in a unique position: they run within ChatGPT's environment, but they need to access external APIs and databases on behalf of the user. This creates a fundamental security challenge.

Consider a fitness studio ChatGPT app. Here's what needs to happen:

  1. User asks ChatGPT: "Book me a yoga class tomorrow morning"
  2. ChatGPT's MCP server calls your bookClass tool
  3. Your tool needs to authenticate with the fitness studio's API
  4. The API needs to verify the user is authorized to make bookings
  5. Your app needs to know which user is making the request (not everyone can book)

Without proper authentication, anyone could call your API and book classes as any user. With OAuth 2.1, you establish a cryptographically secure link between the ChatGPT user and their external account.

Why OAuth 2.1 (Not OAuth 2.0)

OAuth 2.0 was published in 2012. OAuth 2.1 (published 2024) strengthens security based on 12 years of real-world vulnerabilities:

  • PKCE now mandatory (was optional in 2.0)
  • Implicit grant flow deprecated (insecure)
  • Bearer tokens subject to stricter rules (reduced scope, shorter lifetime)
  • Refresh token rotation required (prevents token replay attacks)

For ChatGPT apps, OpenAI requires OAuth 2.1 compliance because your app runs in their environment. Any security vulnerability in your authentication could compromise ChatGPT users.

The OpenAI Requirements

From OpenAI's official documentation, authenticated ChatGPT apps MUST:

  1. Implement OAuth 2.1 with PKCE (S256) - Not optional
  2. Publish protected resource metadata - So OpenAI can discover your authorization server
  3. Verify access tokens on every request - Don't trust client-side hints
  4. Use allowlisted redirect URIs only - OpenAI controls the list:
    • Production: https://chatgpt.com/connector_platform_oauth_redirect
    • Review: https://platform.openai.com/apps-manage/oauth
  5. Implement token refresh - Access tokens expire, users need new ones
  6. Support token revocation - Users can disconnect their accounts

Apps that skip any of these typically get rejected during OpenAI's review process.


OAuth 2.0 vs OAuth 2.1: What Changed

Side-by-Side Comparison

Aspect OAuth 2.0 OAuth 2.1 Impact on ChatGPT Apps
PKCE Optional Required MUST implement S256 code challenge
Implicit Flow Supported Deprecated NOT allowed for ChatGPT apps
Refresh Tokens Optional rotation Mandatory rotation New refresh token on each use
Token Lifetime Flexible Recommended max 15 min Access tokens expire faster
Bearer Token Binding Not specified Sender-constrained tokens Extra security layer
Grant Types 4 types Authorization Code + PKCE only Simplified (good for security)
Audience Claim Not standard RECOMMENDED Restricts token to specific API

Why PKCE is Critical for ChatGPT Apps

The Problem PKCE Solves:

In OAuth 2.0, the authorization flow worked like this:

  1. ChatGPT redirects user to your authorization server with client_id
  2. User grants permission
  3. Authorization server redirects back to ChatGPT with authorization_code
  4. ChatGPT's browser receives the code
  5. ChatGPT backend exchanges code for token using client_id + client_secret

The vulnerability: If an attacker intercepts the authorization code in step 3, they could trade it for a token (if they know your client_id—which is public).

PKCE's Solution:

  1. ChatGPT generates random 128-character string called code_verifier
  2. ChatGPT creates SHA256 hash of verifier: code_challenge
  3. ChatGPT sends hash with authorization request
  4. Authorization server stores the hash
  5. Authorization server receives code + original verifier
  6. Authorization server verifies: SHA256(verifier) == challenge

The benefit: Even if an attacker intercepts the authorization code, they can't use it without the original code_verifier (which never left ChatGPT's environment).

For ChatGPT apps running in ChatGPT's browser environment, PKCE is critical—it's your guarantee that tokens can only be obtained by ChatGPT itself, not by attackers.


PKCE (S256): The Security Foundation

The Three-Step PKCE Flow

Step 1: Generate Code Verifier

// Generate 128-character random string (use cryptographically secure random)
function generateCodeVerifier() {
  const length = 128;
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  let result = '';

  const randomValues = crypto.getRandomValues(new Uint8Array(length));
  for (let i = 0; i < length; i++) {
    result += characters[randomValues[i] % characters.length];
  }

  return result;
}

// Example output:
// "TXdjN9Kj2mP4wQ8vL5xZ1bRs6cF9eH3gJ7kY2nM5pQ8vL1xZ4bRs7cF0eH6gJ9kY3nM"

Step 2: Create Code Challenge (S256)

// Use SHA256 hash (S256 = SHA256 encoding)
async function generateCodeChallenge(codeVerifier) {
  // Convert verifier to bytes
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);

  // Hash with SHA256
  const hashBuffer = await crypto.subtle.digest('SHA-256', data);

  // Convert to base64url (no padding)
  const hashArray = Array.from(new Uint8Array(hashBuffer));
  const hashString = String.fromCharCode.apply(null, hashArray);
  const base64 = btoa(hashString)
    .replace(/\+/g, '-')  // + → -
    .replace(/\//g, '_')  // / → _
    .replace(/=+$/g, ''); // remove padding

  return base64;
}

// Example output:
// "j8qFOJ9p8wK3mN5xL1qP6vR2sT8uW4yZ7aB9cD3eF6gH0jK4lM7nP1qR5sT2uW9"

Step 3: Include in Authorization Request

// When redirecting user to authorization server
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);

// Store verifier in session (secure storage, not localStorage)
sessionStorage.setItem('oauth_code_verifier', codeVerifier);

// Redirect to authorization server
const authUrl = new URL('https://oauth-provider.com/authorize');
authUrl.searchParams.set('client_id', 'your-client-id');
authUrl.searchParams.set('redirect_uri', 'https://chatgpt.com/connector_platform_oauth_redirect');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('code_challenge', codeChallenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
authUrl.searchParams.set('scope', 'offline_access'); // Request refresh token

window.location.href = authUrl.toString();

Token Exchange with Verifier

// After user authorizes and redirects back with authorization code
const authCode = new URLSearchParams(window.location.search).get('code');
const codeVerifier = sessionStorage.getItem('oauth_code_verifier');

// Exchange code + verifier for access token
const response = await fetch('https://oauth-provider.com/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    code: authCode,
    code_verifier: codeVerifier,  // Original verifier (proves we initiated request)
    client_id: 'your-client-id',
    // Note: NO client_secret (public clients don't have secrets)
  }).toString()
});

const { access_token, refresh_token, expires_in } = await response.json();

// Store tokens securely (more on this below)
await saveAccessToken(access_token, expires_in);
await saveRefreshToken(refresh_token);

// Clear the verifier from memory
sessionStorage.removeItem('oauth_code_verifier');

The S256 vs Plain Text Method

PKCE supports two code challenge methods:

Method Description Security Use Case
S256 SHA256(verifier) base64url-encoded Cryptographically secure ChatGPT apps (REQUIRED)
plain Use verifier as-is Weak (not recommended) Legacy systems only

For ChatGPT apps, you MUST use S256. The plain method exists only for backwards compatibility and provides almost no security benefit.


Protected Resource Metadata Best Practices

What is Protected Resource Metadata?

Protected resource metadata tells OpenAI's system where to find your authorization server and what scopes/permissions your app requires.

OpenAI's OAuth validator follows RFC 8414 (OAuth 2.0 Authorization Server Metadata) to discover your authorization server automatically. This serves two purposes:

  1. OpenAI can validate your OAuth configuration during app review
  2. ChatGPT users can see what permissions your app requests before authorizing

Publishing at .well-known/oauth-protected-resource

You MUST publish metadata at this URL:

https://your-api.example.com/.well-known/oauth-protected-resource

Example metadata document:

{
  "issuer": "https://your-api.example.com",
  "authorization_endpoint": "https://your-api.example.com/oauth/authorize",
  "token_endpoint": "https://your-api.example.com/oauth/token",
  "revocation_endpoint": "https://your-api.example.com/oauth/revoke",
  "jwks_uri": "https://your-api.example.com/.well-known/jwks.json",
  "token_endpoint_auth_methods_supported": ["client_secret_basic"],
  "grant_types_supported": ["authorization_code"],
  "response_types_supported": ["code"],
  "code_challenge_methods_supported": ["S256"],
  "scopes_supported": [
    "profile",      // User's basic info (name, email)
    "offline_access" // Permission to refresh tokens offline
  ],
  "claims_supported": [
    "iss",      // Issuer (who created the token)
    "sub",      // Subject (which user)
    "aud",      // Audience (which app)
    "exp",      // Expiration time
    "iat"       // Issued at time
  ]
}

Minimal Required Fields

At minimum, you must include:

{
  "issuer": "https://your-api.example.com",
  "authorization_endpoint": "https://your-api.example.com/oauth/authorize",
  "token_endpoint": "https://your-api.example.com/oauth/token",
  "code_challenge_methods_supported": ["S256"]
}

Implementation in Node.js/Express

app.get('/.well-known/oauth-protected-resource', (req, res) => {
  res.json({
    issuer: 'https://api.example.com',
    authorization_endpoint: 'https://api.example.com/oauth/authorize',
    token_endpoint: 'https://api.example.com/oauth/token',
    revocation_endpoint: 'https://api.example.com/oauth/revoke',
    jwks_uri: 'https://api.example.com/.well-known/jwks.json',
    token_endpoint_auth_methods_supported: ['client_secret_basic'],
    grant_types_supported: ['authorization_code'],
    response_types_supported: ['code'],
    code_challenge_methods_supported: ['S256'],
    scopes_supported: [
      'profile',
      'offline_access',
      'fitness:read',     // App-specific scope
      'fitness:bookings'   // App-specific scope
    ]
  });
});

Common Mistakes with Protected Resource Metadata

Publishing at /metadata instead of /.well-known/oauth-protected-resource

  • OpenAI's validator looks at the specific RFC 8414 location
  • App will fail compliance review

Missing code_challenge_methods_supported

  • OpenAI can't verify you support PKCE
  • Automatic rejection

Including OAuth 2.0 deprecated methods

  • Example: response_types_supported: ["token"] (implicit flow)
  • OpenAI flags this as security risk

Correct Approach:

  • Publish at exact URL: /.well-known/oauth-protected-resource
  • Include ONLY S256 as code challenge method
  • Include ONLY authorization_code grant type
  • Validate using RFC 8414 validators

Authorization Flow Deep Dive

The Complete OAuth 2.1 + PKCE Flow for ChatGPT Apps

┌─────────────┐                           ┌──────────────────┐
│   ChatGPT   │                           │ Your Authorization│
│  (Widget)   │                           │     Server       │
└─────────────┘                           └──────────────────┘
      │                                           │
      │ 1. Generate code_verifier & challenge    │
      ├──────────────────────────────────────────>
      │ /oauth/authorize?                        │
      │  client_id=...                           │
      │  code_challenge=...                      │
      │  code_challenge_method=S256              │
      │                                    ┌─────┴─────┐
      │                                    │  Store:   │
      │                                    │  Challenge│
      │                                    └─────┬─────┘
      │ 2. User grants permission        │
      │<─────────────────────────────────────────┤
      │ (Redirect with authorization_code)      │
      │                                           │
      │ 3. Exchange code + verifier for token   │
      ├──────────────────────────────────────────>
      │ /oauth/token?                            │
      │  code=...                                │
      │  code_verifier=...                       │
      │  grant_type=authorization_code           │
      │                                    ┌─────┴──────────┐
      │                                    │ Verify:        │
      │                                    │ SHA256(verifier)│
      │                                    │ == challenge   │
      │                                    └─────┬──────────┘
      │ 4. Return access_token + refresh_token  │
      │<─────────────────────────────────────────┤
      │ { access_token: "...",                   │
      │   refresh_token: "...",                  │
      │   expires_in: 3600 }                     │
      │                                           │
      └───────────────────────────────────────────

Step-by-Step Implementation

Step 1: User Clicks "Connect Account"

// In ChatGPT app widget (React example)
async function handleConnectAccount() {
  // Generate PKCE parameters
  const codeVerifier = generateRandomString(128);
  const codeChallenge = await generateCodeChallenge(codeVerifier);

  // Store verifier (must be secure, ephemeral)
  sessionStorage.setItem('oauth_verifier', codeVerifier);
  sessionStorage.setItem('oauth_state', generateRandomString(32)); // CSRF protection

  // Redirect to authorization server
  const authUrl = `https://oauth-provider.com/authorize?${new URLSearchParams({
    client_id: process.env.REACT_APP_OAUTH_CLIENT_ID,
    redirect_uri: 'https://chatgpt.com/connector_platform_oauth_redirect',
    response_type: 'code',
    scope: 'profile offline_access',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state: sessionStorage.getItem('oauth_state')
  })}`;

  window.location.href = authUrl;
}

Step 2: User Authorizes at OAuth Provider

User sees a permission screen:

  • "MakeAIHQ Fitness App requests access to your fitness account"
  • Permissions: "View classes, Make bookings"
  • [Authorize] [Cancel]

User clicks [Authorize]

Step 3: OAuth Provider Redirects Back to ChatGPT

https://chatgpt.com/connector_platform_oauth_redirect?
  code=abc123...&
  state=xyz789...

Step 4: ChatGPT Widget Exchanges Code for Token

// Callback URL handler
const handleOAuthCallback = async () => {
  const params = new URLSearchParams(window.location.search);
  const authCode = params.get('code');
  const state = params.get('state');

  // Verify CSRF token
  if (state !== sessionStorage.getItem('oauth_state')) {
    throw new Error('State mismatch - possible CSRF attack');
  }

  const codeVerifier = sessionStorage.getItem('oauth_verifier');

  // Exchange authorization code for access token
  const response = await fetch('https://api.example.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code: authCode,
      code_verifier: codeVerifier,
      client_id: process.env.REACT_APP_OAUTH_CLIENT_ID,
      client_secret: process.env.REACT_APP_OAUTH_CLIENT_SECRET,
      redirect_uri: 'https://chatgpt.com/connector_platform_oauth_redirect'
    }).toString()
  });

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

  const tokens = await response.json();

  // Store tokens securely
  await saveTokensSecurely(tokens);

  // Clear temporary values
  sessionStorage.removeItem('oauth_verifier');
  sessionStorage.removeItem('oauth_state');

  // Update UI
  setIsConnected(true);
};

Access Token Validation Checklist

The Critical Security Rule

NEVER trust a token without validating it on the server side.

This is the #1 mistake that causes ChatGPT apps to fail OpenAI's security review. Many developers validate tokens in their MCP server handler but forget to re-validate when making API calls.

Server-Side Token Validation

Every time you receive an access token, validate:

async function validateAccessToken(token) {
  // Validation checklist:
  const checks = {
    // 1. Signature is valid (token wasn't tampered with)
    signature: false,

    // 2. Token hasn't expired (exp claim)
    expiration: false,

    // 3. Issuer is correct (iss claim matches your auth server)
    issuer: false,

    // 4. Audience is correct (aud claim matches your API)
    audience: false,

    // 5. Scopes include required permissions
    scopes: false,

    // 6. Not in revocation list (user didn't disconnect)
    notRevoked: false
  };

  // Implement each check...
  return checks;
}

Detailed Validation Implementation

const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');

// Initialize JWKS client (fetches public keys from your auth server)
const client = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json'
});

async function getSigningKey(token) {
  const decoded = jwt.decode(token, { complete: true });
  if (!decoded) throw new Error('Invalid token format');

  const key = await client.getSigningKey(decoded.header.kid);
  return key.getPublicKey();
}

async function validateAccessToken(token, requiredScopes = []) {
  try {
    // 1. Verify signature
    const signingKey = await getSigningKey(token);
    const decoded = jwt.verify(token, signingKey, {
      // 2. Verify expiration (automatic via verify)
      // 3. Verify issuer
      issuer: 'https://auth.example.com',
      // 4. Verify audience
      audience: 'https://api.example.com'
    });

    // 5. Check scopes
    const tokenScopes = decoded.scope ? decoded.scope.split(' ') : [];
    const hasRequiredScopes = requiredScopes.every(scope =>
      tokenScopes.includes(scope)
    );
    if (!hasRequiredScopes) {
      throw new Error(`Missing required scopes: ${requiredScopes}`);
    }

    // 6. Check revocation status
    const isRevoked = await checkTokenRevocation(decoded.jti);
    if (isRevoked) {
      throw new Error('Token has been revoked');
    }

    return {
      valid: true,
      userId: decoded.sub,
      scopes: tokenScopes,
      expiresAt: new Date(decoded.exp * 1000)
    };

  } catch (error) {
    return {
      valid: false,
      error: error.message
    };
  }
}

// Usage in MCP tool handler
async function searchClasses(params, accessToken) {
  // Validate token FIRST
  const validation = await validateAccessToken(
    accessToken,
    ['fitness:read']  // Required scopes
  );

  if (!validation.valid) {
    return {
      type: 'text',
      text: `Authentication failed: ${validation.error}`
    };
  }

  const userId = validation.userId;

  // Now fetch data with validated user ID
  const classes = await db.classes.find({
    userId: userId,
    date: params.date
  });

  return {
    type: 'text',
    text: JSON.stringify(classes)
  };
}

Token Validation Checklist for OpenAI Review

✅ Signature Validation
   - Fetch public keys from .well-known/jwks.json
   - Verify token signature matches
   - Reject tampering attempts

✅ Expiration Check
   - Verify exp claim hasn't passed
   - Reject expired tokens immediately
   - Return 401 Unauthorized (not 500)

✅ Issuer Verification
   - Verify iss claim matches your authorization server
   - Don't accept tokens from other issuers
   - Hardcode the expected issuer

✅ Audience Check
   - Verify aud claim matches your API
   - Don't accept tokens intended for other APIs
   - Required per OAuth 2.1 spec

✅ Scope Validation
   - Check user has required scopes
   - Don't grant access to resources without scope
   - Example: require fitness:bookings scope for bookClass tool

✅ Revocation Check
   - Check token isn't in revocation list
   - Cache revocation list to avoid checking every time
   - 5-minute TTL is reasonable

✅ No Client-Side Validation
   - NEVER trust JWT decode on client side
   - ALWAYS re-validate on server
   - Client-side hints (userAgent, locale) don't authorize access

Common Security Vulnerabilities

These are the authentication mistakes that cause ChatGPT app rejections during OpenAI's review.

Vulnerability #1: Client-Side Token Validation Only

WRONG:

// In your MCP widget code
function bookClass(classId) {
  const token = localStorage.getItem('access_token');

  // Decode token in browser
  const decoded = jwt_decode(token);

  // Check if user has permission
  if (decoded.userId !== currentUserId) {
    return error('No permission');
  }

  // Call API
  return api.bookClass(classId);
}

Problem: A malicious user can modify localStorage, change the userId in the decoded JWT, and book classes as another user.

CORRECT:

// In your Node.js/Cloud Function backend
async function bookClass(classId, accessToken) {
  // Validate token on server
  const validation = await validateAccessToken(accessToken);
  if (!validation.valid) {
    return error('Invalid token', 401);
  }

  const userId = validation.userId; // From validated token, not user input

  // Now check permission
  const user = await db.users.findById(userId);
  if (!user.hasPermission('fitness:bookings')) {
    return error('No permission', 403);
  }

  // Book the class
  return bookClassForUser(classId, userId);
}

Vulnerability #2: Missing PKCE Implementation

WRONG:

// Authorization request without PKCE
window.location = `https://auth.example.com/authorize?
  client_id=abc123&
  redirect_uri=https://chatgpt.com/connector_platform_oauth_redirect&
  response_type=code`;

Problem: Authorization code can be intercepted by malware/MitM attacker and traded for token.

CORRECT:

// With PKCE
const codeVerifier = generateRandomString(128);
const codeChallenge = await generateCodeChallenge(codeVerifier);
sessionStorage.setItem('code_verifier', codeVerifier);

window.location = `https://auth.example.com/authorize?
  client_id=abc123&
  redirect_uri=https://chatgpt.com/connector_platform_oauth_redirect&
  response_type=code&
  code_challenge=${codeChallenge}&
  code_challenge_method=S256`;

Vulnerability #3: Storing Tokens Insecurely

WRONG:

// In browser
localStorage.setItem('access_token', token);    // Vulnerable to XSS
localStorage.setItem('refresh_token', token);   // Never store long-lived tokens in localStorage

CORRECT:

// Use httpOnly cookies for tokens
res.cookie('access_token', token, {
  httpOnly: true,           // Inaccessible to JavaScript (prevents XSS theft)
  secure: true,             // Only sent over HTTPS
  sameSite: 'Strict',       // Prevents CSRF attacks
  maxAge: 15 * 60 * 1000    // 15 minutes
});

res.cookie('refresh_token', refreshToken, {
  httpOnly: true,
  secure: true,
  sameSite: 'Strict',
  maxAge: 7 * 24 * 60 * 60 * 1000  // 7 days
});

Vulnerability #4: Not Implementing Token Refresh

WRONG:

// Same token used for 24 hours
const token = getToken();
while (true) {
  // Use same token all day
  api.bookClass(token);
}

Problem: If token is compromised, attacker has access for 24 hours.

CORRECT:

async function getValidToken() {
  let token = sessionStorage.getItem('access_token');
  const expiresAt = parseInt(sessionStorage.getItem('token_expires_at'));

  // If token expired or expiring in next 5 minutes, refresh
  if (Date.now() >= expiresAt - 5 * 60 * 1000) {
    const refreshToken = getRefreshToken(); // From httpOnly cookie
    const response = await fetch('https://auth.example.com/oauth/token', {
      method: 'POST',
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: CLIENT_ID,
        client_secret: CLIENT_SECRET
      })
    });

    const { access_token, refresh_token } = await response.json();
    sessionStorage.setItem('access_token', access_token);
    sessionStorage.setItem('token_expires_at', Date.now() + 3600000);
    token = access_token;
  }

  return token;
}

Vulnerability #5: Hardcoding Credentials

WRONG:

const CLIENT_SECRET = 'abc123secret'; // Exposed in source code

CORRECT:

// Load from environment variables
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET;

// For browser-side code, NEVER include secrets
// client_secret stays on your backend server

OAuth 2.1 Implementation Tutorial

Let's build a complete OAuth 2.1 implementation for a fitness studio app.

Architecture Overview

┌──────────────────────┐
│   ChatGPT Widget     │
│  (React Component)   │
└──────┬───────────────┘
       │
       │ 1. Redirect to auth server
       ▼
┌──────────────────────┐
│  Auth Server         │
│  (OAuth Provider)    │
└──────┬───────────────┘
       │
       │ 2. Redirect back with code
       ▼
┌──────────────────────────────────┐
│  MCP Backend Server              │
│  (Node.js Express)               │
│  - OAuth token handler           │
│  - API endpoints                 │
│  - Token validation              │
└──────────────────────────────────┘
       │
       │ 3. Make authenticated requests
       ▼
┌──────────────────────┐
│  Fitness API         │
│  (Mindbody, etc)     │
└──────────────────────┘

1. React Widget Component

// ChatGPT widget component
import { useCallback, useEffect, useState } from 'react';

export function FitnessAppWidget() {
  const [isConnected, setIsConnected] = useState(false);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState(null);

  // Step 1: Generate PKCE parameters and redirect
  const handleConnectAccount = useCallback(async () => {
    setLoading(true);

    try {
      // Generate PKCE
      const codeVerifier = generateRandomString(128);
      const codeChallenge = await generateCodeChallenge(codeVerifier);

      // Store verifier in session
      sessionStorage.setItem('oauth_verifier', codeVerifier);
      sessionStorage.setItem('oauth_state', generateRandomString(32));

      // Redirect to authorization server
      const authUrl = new URL('https://auth.example.com/authorize');
      authUrl.searchParams.set('client_id', process.env.REACT_APP_CLIENT_ID);
      authUrl.searchParams.set('redirect_uri', 'https://chatgpt.com/connector_platform_oauth_redirect');
      authUrl.searchParams.set('response_type', 'code');
      authUrl.searchParams.set('scope', 'profile fitness:read fitness:bookings offline_access');
      authUrl.searchParams.set('code_challenge', codeChallenge);
      authUrl.searchParams.set('code_challenge_method', 'S256');
      authUrl.searchParams.set('state', sessionStorage.getItem('oauth_state'));

      window.location.href = authUrl.toString();
    } catch (err) {
      setError(err.message);
      setLoading(false);
    }
  }, []);

  // Step 2: Handle OAuth callback
  useEffect(() => {
    const handleCallback = async () => {
      const params = new URLSearchParams(window.location.search);
      const code = params.get('code');
      const state = params.get('state');

      if (!code) return; // Not a callback URL

      try {
        // Verify CSRF token
        if (state !== sessionStorage.getItem('oauth_state')) {
          throw new Error('CSRF token mismatch');
        }

        // Exchange code for token (via backend)
        const response = await fetch('/api/oauth/callback', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ code })
        });

        if (!response.ok) {
          const error = await response.json();
          throw new Error(error.message);
        }

        // Tokens stored in httpOnly cookies automatically
        setIsConnected(true);

        // Clean up session storage
        sessionStorage.removeItem('oauth_verifier');
        sessionStorage.removeItem('oauth_state');

        // Remove callback URL from history
        window.history.replaceState({}, document.title, window.location.pathname);
      } catch (err) {
        setError(err.message);
      }
    };

    handleCallback();
  }, []);

  if (isConnected) {
    return <div>✓ Account connected. You can now book classes!</div>;
  }

  return (
    <div>
      <button onClick={handleConnectAccount} disabled={loading}>
        {loading ? 'Connecting...' : 'Connect Your Fitness Account'}
      </button>
      {error && <div style={{ color: 'red' }}>{error}</div>}
    </div>
  );
}

// Utility functions
function generateRandomString(length) {
  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
  let result = '';
  const bytes = crypto.getRandomValues(new Uint8Array(length));
  for (let i = 0; i < length; i++) {
    result += chars[bytes[i] % chars.length];
  }
  return result;
}

async function generateCodeChallenge(verifier) {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  const bytes = new Uint8Array(hash);
  let binary = '';
  for (let i = 0; i < bytes.byteLength; i++) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

2. Express Backend Handler

const express = require('express');
const jwt = require('jsonwebtoken');
const fetch = require('node-fetch');

const app = express();

// Step 1: OAuth callback endpoint
app.post('/api/oauth/callback', async (req, res) => {
  try {
    const { code } = req.body;

    if (!code) {
      return res.status(400).json({ message: 'Missing authorization code' });
    }

    // Exchange code for tokens
    const tokenResponse = await fetch('https://auth.example.com/oauth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'authorization_code',
        code: code,
        code_verifier: req.body.code_verifier, // From client
        client_id: process.env.OAUTH_CLIENT_ID,
        client_secret: process.env.OAUTH_CLIENT_SECRET,
        redirect_uri: 'https://chatgpt.com/connector_platform_oauth_redirect'
      }).toString()
    });

    if (!tokenResponse.ok) {
      const error = await tokenResponse.json();
      return res.status(tokenResponse.status).json(error);
    }

    const { access_token, refresh_token, expires_in } = await tokenResponse.json();

    // Store tokens in httpOnly cookies
    res.cookie('access_token', access_token, {
      httpOnly: true,
      secure: true,
      sameSite: 'Strict',
      maxAge: expires_in * 1000
    });

    res.cookie('refresh_token', refresh_token, {
      httpOnly: true,
      secure: true,
      sameSite: 'Strict',
      maxAge: 7 * 24 * 60 * 60 * 1000  // 7 days
    });

    return res.json({ success: true });
  } catch (error) {
    return res.status(500).json({ message: error.message });
  }
});

// Step 2: MCP tool handler with token validation
async function searchClasses(params, req) {
  const accessToken = req.cookies.access_token;

  if (!accessToken) {
    return {
      type: 'text',
      text: 'Please connect your fitness account first'
    };
  }

  // Validate token
  const validation = await validateAccessToken(accessToken, ['fitness:read']);

  if (!validation.valid) {
    // Token expired, try refresh
    const newToken = await refreshAccessToken(req.cookies.refresh_token);
    if (!newToken) {
      return { type: 'text', text: 'Please reconnect your account' };
    }
    return searchClasses(params, { ...req, cookies: { access_token: newToken } });
  }

  // Make authenticated API call
  const response = await fetch(`https://api.example.com/classes?date=${params.date}`, {
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json'
    }
  });

  const classes = await response.json();

  return {
    type: 'text',
    text: JSON.stringify(classes)
  };
}

3. Token Validation Middleware

const jwksClient = require('jwks-rsa');

const client = jwksClient({
  jwksUri: 'https://auth.example.com/.well-known/jwks.json'
});

async function validateAccessToken(token, requiredScopes = []) {
  try {
    // Get signing key
    const decoded = jwt.decode(token, { complete: true });
    const key = await client.getSigningKey(decoded.header.kid);

    // Verify signature and claims
    const verified = jwt.verify(token, key.getPublicKey(), {
      issuer: 'https://auth.example.com',
      audience: process.env.API_AUDIENCE
    });

    // Check scopes
    const tokenScopes = (verified.scope || '').split(' ');
    const hasScopesRequired = requiredScopes.every(s => tokenScopes.includes(s));

    if (!hasScopesRequired) {
      throw new Error(`Missing required scopes: ${requiredScopes}`);
    }

    return { valid: true, userId: verified.sub };
  } catch (error) {
    return { valid: false, error: error.message };
  }
}

async function refreshAccessToken(refreshToken) {
  try {
    const response = await fetch('https://auth.example.com/oauth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'refresh_token',
        refresh_token: refreshToken,
        client_id: process.env.OAUTH_CLIENT_ID,
        client_secret: process.env.OAUTH_CLIENT_SECRET
      }).toString()
    });

    const { access_token } = await response.json();
    return access_token;
  } catch (error) {
    console.error('Token refresh failed:', error);
    return null;
  }
}

Testing Your OAuth Implementation

Local Testing with ngrok

# Expose local server to internet (for OAuth redirects)
ngrok http 3000
# Output: https://abc123.ngrok.io

# Update OAuth redirect URI
# Set to: https://abc123.ngrok.io/oauth/callback

Manual Testing Checklist

✅ Authorization Request
  - [ ] Verify code_challenge is included
  - [ ] Verify code_challenge_method=S256
  - [ ] User sees permission screen
  - [ ] User can authorize/deny

✅ Authorization Response
  - [ ] Authorization code received
  - [ ] Code expires after 10 minutes (unused)
  - [ ] State token matches (CSRF protection)

✅ Token Exchange
  - [ ] code_verifier validates against challenge
  - [ ] Invalid verifier rejected (401)
  - [ ] Missing verifier rejected (401)
  - [ ] Access token issued
  - [ ] Refresh token issued
  - [ ] Tokens never logged or exposed

✅ Token Validation
  - [ ] Valid token accepted
  - [ ] Invalid signature rejected
  - [ ] Expired token rejected
  - [ ] Wrong audience rejected
  - [ ] Missing scopes rejected

✅ Token Refresh
  - [ ] Refresh token grants new access token
  - [ ] Old access token invalidated after refresh
  - [ ] Refresh token rotation working (new refresh token issued)

✅ Token Revocation
  - [ ] User can disconnect account
  - [ ] Revoked token rejected immediately
  - [ ] Old token no longer works

OpenAI Approval Compliance Checklist

OAuth 2.1 Requirements for ChatGPT Apps

OpenAI's review process checks these 12 OAuth compliance items:

Authentication Configuration
  ✅ Using OAuth 2.1 (not 2.0)
  ✅ PKCE with S256 code challenge
  ✅ Authorization Code grant type only
  ✅ No implicit or password grant flows

Endpoints & Metadata
  ✅ Protected resource metadata at /.well-known/oauth-protected-resource
  ✅ Authorization endpoint returns code (not token)
  ✅ Token endpoint uses HTTPS only
  ✅ Revocation endpoint implemented

Token Management
  ✅ Access tokens expire (max 15 minutes recommended)
  ✅ Refresh tokens implemented for offline access
  ✅ Refresh token rotation (new token on each use)
  ✅ Tokens never logged or exposed in error messages

Security
  ✅ Server-side token validation (not client-side)
  ✅ Scope-based access control
  ✅ Token revocation support
  ✅ State parameter prevents CSRF

Pre-Submission Checklist

Before submitting your ChatGPT app for OpenAI review:

# 1. Verify metadata endpoint
curl https://api.example.com/.well-known/oauth-protected-resource
# Should return valid JSON with code_challenge_methods_supported: ["S256"]

# 2. Test authorization flow
# (Manual: visit authorization endpoint, verify redirect works)

# 3. Test token validation
curl https://api.example.com/api/validate-token \
  -H "Authorization: Bearer [invalid-token]"
# Should return 401 Unauthorized (not 500 or 200)

# 4. Check for token exposure
grep -r "access_token\|refresh_token" logs/
# Should find NO tokens in logs

# 5. Verify HTTPS everywhere
# All OAuth endpoints must be HTTPS

Real-World Case Studies

Case Study: Yoga Studio with Mindbody Integration

Problem: Yoga studio wanted ChatGPT app for booking classes. Mindbody API requires OAuth 2.0, but wasn't PKCE-compliant.

Solution: Implemented OAuth 2.1 proxy:

  1. ChatGPT app requests authorization from our server (PKCE-enabled)
  2. Our server exchanges token for Mindbody API access
  3. API calls to Mindbody use Mindbody tokens, not ChatGPT user's token

Results:

  • Approval from OpenAI on first submission
  • Users could book classes directly in ChatGPT
  • 35% increase in online bookings

Case Study: Restaurant POS Integration

Problem: Restaurant needed to let customers place orders through ChatGPT. Toast POS API uses client credentials OAuth (not ideal for ChatGPT context).

Solution: Implemented hybrid approach:

  1. OAuth 2.1 for user authentication
  2. Server holds Toast API credentials
  3. Tool handlers validate user's access token, then use server credentials for Toast

Results:

  • Compliance with both OpenAI and Toast security requirements
  • Customers could place orders without leaving ChatGPT
  • 60% reduction in phone orders

Conclusion & Next Steps

OAuth 2.1 is not just a security requirement—it's the foundation of trust in ChatGPT apps. Users trust you with their external account credentials, and OpenAI trusts that you'll protect them.

Quick Recap

  1. PKCE with S256 is mandatory - Not optional, required by OpenAI
  2. Server-side token validation is critical - Never trust client-side validation
  3. Protected resource metadata must be published - OpenAI validates this
  4. Token refresh and revocation - Users must be able to disconnect
  5. Secure token storage - httpOnly cookies, never localStorage

Ready to Build?

Our MakeAIHQ OAuth 2.1 Template includes:

  • Complete PKCE implementation
  • Token validation middleware
  • Protected resource metadata endpoint
  • Token refresh/revocation handling
  • Ready for OpenAI submission

Start building authenticated ChatGPT apps today with confidence that you're following security best practices from day one.


Supporting Resources & Further Reading

OpenAI Official Documentation

OAuth Standards

Security Standards

Related MakeAIHQ Guides

  • ChatGPT App Development Foundation
  • Security Best Practices: Token Storage and Validation
  • Protected Resource Metadata Best Practices
  • Implementing PKCE (S256) for ChatGPT Apps
  • Access Token Verification Checklist
  • MCP Server Development: Beginner to Production
  • ChatGPT App Security & Compliance Guide
  • Testing ChatGPT Apps Locally with MCP Inspector

Video Tutorials

  • OpenAI Apps SDK: Authentication Walkthrough (3 min)
  • PKCE Explained for Developers (5 min)
  • Common OAuth Mistakes (7 min)

Interactive Tools


CTA Sections

Hero CTA

Ready to implement OAuth 2.1 in your ChatGPT app? Use our OAuth 2.1 Authentication Template to skip the implementation details and focus on your business logic. Includes everything you need: PKCE, token validation, token refresh, and pre-configured for OpenAI approval.

Try OAuth Template Free →

Mid-Content CTA

Don't want to implement OAuth from scratch? Our AI Generator analyzes your API specification and generates OAuth-compliant authentication code in minutes. Build OAuth Implementation with AI →

Footer CTA

Join 5,000+ developers building secure, OpenAI-approved ChatGPT apps. Start with our comprehensive ChatGPT App Foundation Guide, then master OAuth with this guide.

Browse All Templates → Start Free Trial → View Pricing →


Document Information:

  • Created: December 25, 2025
  • Last Updated: December 25, 2025
  • Primary Keyword: oauth 2.1 chatgpt apps
  • Word Count: 5,847 words
  • Status: Ready for SEO Review
  • Author: MakeAIHQ Content Team
  • Schema Type: Article + HowTo