OAuth Refresh Token Management: Keep ChatGPT App Sessions Alive for 90+ Days
OAuth refresh tokens are long-lived credentials that enable ChatGPT apps to maintain authenticated sessions without forcing users to re-login every hour. While access tokens expire in 15-60 minutes for security, refresh tokens can last 30-90 days or longer, providing a seamless user experience.
For ChatGPT apps, proper refresh token management is critical. Unlike traditional web apps where users actively browse, ChatGPT apps operate in conversational contexts where authentication interruptions feel jarring. A user asking "What's on my calendar today?" shouldn't hit a login wall because their token expired 30 minutes ago.
However, refresh tokens are powerful credentials. If stolen, they grant persistent access to user accounts. This guide covers secure implementation patterns for token rotation, silent refresh, and storage that meet OpenAI's security requirements and OAuth 2.1 best practices.
Token Rotation: One-Time Use Refresh Tokens
The OAuth 2.1 specification mandates refresh token rotation for public clients (which includes all ChatGPT apps, since they can't securely store secrets). Every time you use a refresh token to obtain new access tokens, the authorization server MUST issue a new refresh token and invalidate the old one.
Why Rotation Matters
Refresh token rotation limits the window of vulnerability if a token is stolen. With non-rotating tokens, a single compromised token grants access until manual revocation. With rotation, stolen tokens become useless after the legitimate client refreshes once.
Implementing Rotation
Here's a production-ready token refresh handler with rotation support:
class TokenManager {
constructor(authServerUrl, clientId) {
this.authServerUrl = authServerUrl;
this.clientId = clientId;
this.refreshPromise = null; // Prevent concurrent refreshes
}
async refreshAccessToken(currentRefreshToken) {
// Prevent concurrent refresh requests
if (this.refreshPromise) {
return this.refreshPromise;
}
this.refreshPromise = (async () => {
try {
const response = await fetch(`${this.authServerUrl}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: currentRefreshToken,
client_id: this.clientId,
}),
});
if (!response.ok) {
// Token rotation failure - likely stolen token detected
if (response.status === 400) {
const error = await response.json();
if (error.error === 'invalid_grant') {
// Refresh token already used - possible theft
await this.revokeAllTokens();
throw new Error('ROTATION_FAILURE');
}
}
throw new Error(`Token refresh failed: ${response.status}`);
}
const tokens = await response.json();
// Store new tokens (new access token AND new refresh token)
await this.storeTokens(tokens.access_token, tokens.refresh_token);
return tokens.access_token;
} finally {
this.refreshPromise = null;
}
})();
return this.refreshPromise;
}
}
Rotation Failure Detection
If a refresh token is used twice (once by the legitimate client, once by an attacker), the authorization server MUST reject the second request. Your app should detect this and immediately revoke all tokens for that user:
async handleRotationFailure(userId) {
// Log security event
console.error(`[SECURITY] Refresh token reuse detected for user ${userId}`);
// Revoke all tokens for this user
await fetch(`${this.authServerUrl}/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: this.currentRefreshToken,
client_id: this.clientId,
}),
});
// Clear local storage
this.clearTokens();
// Force re-authentication
window.location.href = '/login?reason=security';
}
Silent Token Refresh: Invisible Renewals
Silent token refresh means renewing access tokens in the background, before they expire, without user interaction. This creates a seamless experience where authentication never interrupts conversation flow.
Proactive Refresh Strategy
Don't wait for access tokens to expire. Refresh them 5-10 minutes before expiration to avoid race conditions where a token expires mid-request:
class SilentRefreshManager {
constructor(tokenManager) {
this.tokenManager = tokenManager;
this.refreshTimer = null;
}
scheduleRefresh(accessToken) {
// Decode JWT to get expiration (exp claim)
const payload = JSON.parse(atob(accessToken.split('.')[1]));
const expiresAt = payload.exp * 1000; // Convert to milliseconds
const now = Date.now();
// Refresh 5 minutes before expiration
const refreshAt = expiresAt - (5 * 60 * 1000);
const delay = Math.max(refreshAt - now, 0);
// Clear existing timer
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
}
// Schedule proactive refresh
this.refreshTimer = setTimeout(async () => {
try {
const newAccessToken = await this.tokenManager.refreshAccessToken(
this.getRefreshToken()
);
this.scheduleRefresh(newAccessToken); // Schedule next refresh
} catch (error) {
console.error('Silent refresh failed:', error);
// Fallback to reactive refresh on next API call
}
}, delay);
}
stop() {
if (this.refreshTimer) {
clearTimeout(this.refreshTimer);
this.refreshTimer = null;
}
}
}
Request Queuing During Refresh
When a refresh is in progress, queue pending API requests instead of failing them. This prevents race conditions where multiple requests trigger concurrent refreshes:
class RequestQueue {
constructor() {
this.queue = [];
this.isRefreshing = false;
}
async executeWithRefresh(apiCall, tokenManager) {
// If already refreshing, queue this request
if (this.isRefreshing) {
return new Promise((resolve, reject) => {
this.queue.push({ apiCall, resolve, reject });
});
}
try {
// Attempt API call
return await apiCall();
} catch (error) {
// If 401 Unauthorized, refresh and retry
if (error.status === 401 && !this.isRefreshing) {
this.isRefreshing = true;
try {
// Refresh tokens
await tokenManager.refreshAccessToken(tokenManager.getRefreshToken());
// Retry original request
const result = await apiCall();
// Process queued requests
while (this.queue.length > 0) {
const { apiCall: queuedCall, resolve, reject } = this.queue.shift();
try {
resolve(await queuedCall());
} catch (err) {
reject(err);
}
}
return result;
} finally {
this.isRefreshing = false;
}
}
throw error;
}
}
}
Secure Storage: Never Use localStorage
Refresh tokens are sensitive credentials that grant long-term access. Never store them in localStorage or sessionStorage - these are vulnerable to XSS attacks. Use httpOnly cookies instead.
httpOnly Cookie Storage
httpOnly cookies are inaccessible to JavaScript, protecting them from XSS:
// Server-side: Set refresh token in httpOnly cookie
app.post('/auth/token', async (req, res) => {
const { access_token, refresh_token } = await exchangeAuthorizationCode(
req.body.code
);
// Store refresh token in httpOnly cookie (protected from XSS)
res.cookie('refresh_token', refresh_token, {
httpOnly: true, // JavaScript cannot access
secure: true, // HTTPS only
sameSite: 'Strict', // CSRF protection
maxAge: 90 * 24 * 60 * 60 * 1000, // 90 days
});
// Return access token (short-lived, can be in memory)
res.json({ access_token });
});
Refresh Token Encryption
For extra security, encrypt refresh tokens before storage:
const crypto = require('crypto');
function encryptRefreshToken(refreshToken, secret) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(secret), iv);
let encrypted = cipher.update(refreshToken, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Store: iv + authTag + encrypted
return iv.toString('hex') + authTag.toString('hex') + encrypted;
}
function decryptRefreshToken(encryptedToken, secret) {
const iv = Buffer.from(encryptedToken.slice(0, 32), 'hex');
const authTag = Buffer.from(encryptedToken.slice(32, 64), 'hex');
const encrypted = encryptedToken.slice(64);
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(secret), iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
Token Revocation API
Implement a revocation endpoint to allow users to explicitly log out and invalidate refresh tokens:
app.post('/auth/revoke', async (req, res) => {
const refreshToken = req.cookies.refresh_token;
if (refreshToken) {
// Call authorization server's revocation endpoint
await fetch(`${AUTH_SERVER_URL}/revoke`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
token: refreshToken,
client_id: CLIENT_ID,
}),
});
}
// Clear cookie
res.clearCookie('refresh_token');
res.json({ success: true });
});
Production Implementation: Axios Interceptor
For React/Node.js apps, use Axios interceptors to automatically refresh tokens on 401 responses:
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.yourapp.com',
withCredentials: true, // Send httpOnly cookies
});
let isRefreshing = false;
let refreshSubscribers = [];
function subscribeTokenRefresh(callback) {
refreshSubscribers.push(callback);
}
function onTokenRefreshed(newAccessToken) {
refreshSubscribers.forEach((callback) => callback(newAccessToken));
refreshSubscribers = [];
}
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Queue this request until refresh completes
return new Promise((resolve) => {
subscribeTokenRefresh((newAccessToken) => {
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
resolve(api(originalRequest));
});
});
}
originalRequest._retry = true;
isRefreshing = true;
try {
// Call server refresh endpoint (uses httpOnly cookie)
const { data } = await axios.post('/auth/refresh', {}, {
withCredentials: true,
});
const newAccessToken = data.access_token;
// Update authorization header
api.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
originalRequest.headers['Authorization'] = `Bearer ${newAccessToken}`;
// Notify queued requests
onTokenRefreshed(newAccessToken);
// Retry original request
return api(originalRequest);
} catch (refreshError) {
// Refresh failed - redirect to login
window.location.href = '/login?session_expired=true';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
export default api;
Session Timeout and UX
When refresh tokens expire (after 90 days), handle gracefully:
- Detect Expiration: Check for
invalid_granterror on refresh attempts - Clear State: Remove all tokens and session data
- Inform User: Show a friendly message ("Your session expired. Please log in again.")
- Preserve Context: Store the user's last action to resume after re-authentication
function handleSessionExpiration() {
// Store current context
sessionStorage.setItem('preAuthUrl', window.location.pathname);
// Clear tokens
localStorage.removeItem('access_token');
// httpOnly refresh token cleared server-side
// Redirect with context
window.location.href = '/login?expired=true&return=' +
encodeURIComponent(window.location.pathname);
}
Related Resources
For comprehensive OAuth 2.1 implementation guidance, see our OAuth 2.1 for ChatGPT Apps: Complete Implementation Guide. Learn about ChatGPT App Security Best Practices including token storage and XSS prevention.
External references:
- OAuth 2.1 Specification - Refresh token rotation requirements
- OWASP OAuth Security Cheat Sheet - Token storage best practices
- Auth0: Refresh Token Rotation - Industry implementation patterns
Build ChatGPT apps with production-ready OAuth at MakeAIHQ.com - no-code OAuth 2.1 setup in 5 minutes.
Next Steps:
- Implement PKCE for ChatGPT Apps
- Set up Token Introspection
- Learn OAuth Error Handling