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
- Why OAuth 2.1 Matters for ChatGPT Apps
- OAuth 2.0 vs OAuth 2.1: What Changed
- PKCE (S256): The Security Foundation
- Protected Resource Metadata Best Practices
- Authorization Flow Deep Dive
- Access Token Validation Checklist
- Common Security Vulnerabilities
- OAuth 2.1 Implementation Tutorial
- Testing Your OAuth Implementation
- OpenAI Approval Compliance Checklist
- 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:
- User asks ChatGPT: "Book me a yoga class tomorrow morning"
- ChatGPT's MCP server calls your
bookClasstool - Your tool needs to authenticate with the fitness studio's API
- The API needs to verify the user is authorized to make bookings
- 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:
- Implement OAuth 2.1 with PKCE (S256) - Not optional
- Publish protected resource metadata - So OpenAI can discover your authorization server
- Verify access tokens on every request - Don't trust client-side hints
- 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
- Production:
- Implement token refresh - Access tokens expire, users need new ones
- 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:
- ChatGPT redirects user to your authorization server with
client_id - User grants permission
- Authorization server redirects back to ChatGPT with
authorization_code - ChatGPT's browser receives the code
- 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:
- ChatGPT generates random 128-character string called
code_verifier - ChatGPT creates SHA256 hash of verifier:
code_challenge - ChatGPT sends hash with authorization request
- Authorization server stores the hash
- Authorization server receives code + original verifier
- 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:
- OpenAI can validate your OAuth configuration during app review
- 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:
- ChatGPT app requests authorization from our server (PKCE-enabled)
- Our server exchanges token for Mindbody API access
- 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:
- OAuth 2.1 for user authentication
- Server holds Toast API credentials
- 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
- PKCE with S256 is mandatory - Not optional, required by OpenAI
- Server-side token validation is critical - Never trust client-side validation
- Protected resource metadata must be published - OpenAI validates this
- Token refresh and revocation - Users must be able to disconnect
- 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
- OAuth 2.1 Authorization Framework
- PKCE (RFC 7636)
- OAuth 2.0 Authorization Server Metadata (RFC 8414)
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
- OAuth 2.1 Flow Visualizer
- PKCE Code Challenge Generator
- JWT Validator & Debugger
- OAuth Server Metadata Validator
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