Token Security: JWT Validation, Refresh Tokens & Signature Verification
When you're building ChatGPT apps that handle OAuth 2.1 authentication, token security isn't optional—it's the firewall between your user data and attackers. A single vulnerable JWT validation check can expose 10,000 user accounts. A missing refresh token rotation allows session hijacking. Weak signature verification enables complete authentication bypass.
In this production-ready guide, you'll master the five pillars of token security: JWT signature verification, claim validation, refresh token rotation, token revocation, and secure storage. Every code example is battle-tested, production-ready TypeScript that prevents the vulnerabilities that cause OpenAI app rejections and security breaches.
By the end, you'll implement token security that passes penetration tests, satisfies compliance audits, and protects your ChatGPT app users from the 7 most common token-based attacks.
Table of Contents
- Why Token Security Prevents Unauthorized ChatGPT Access
- JWT Validation: Signature Verification & Claim Checks
- Refresh Token Rotation: Automatic Rotation & Reuse Detection
- Token Revocation: Blacklist Patterns & Redis Integration
- Claim Validation: Issuer, Audience, Expiration
- Token Storage: Secure Storage & httpOnly Cookies
- Production Checklist & Next Steps
Why Token Security Prevents Unauthorized ChatGPT Access {#why-token-security}
The Attack Surface: What Happens When Tokens Fail
ChatGPT apps rely on OAuth 2.1 access tokens to authenticate API requests. Your MCP server receives these tokens with every tool call. If you don't validate tokens properly, attackers can:
Attack #1: Token Forgery
- Attacker creates a fake JWT with arbitrary claims
- Your server trusts the token without signature verification
- Result: Complete authentication bypass, access to any user account
Attack #2: Token Replay
- Attacker steals an expired token from network traffic
- Your server doesn't check expiration claims
- Result: Persistent access even after logout
Attack #3: Refresh Token Theft
- Attacker steals a refresh token (long-lived credentials)
- Your server doesn't rotate refresh tokens on use
- Result: Permanent session hijacking
Attack #4: Cross-Service Token Abuse
- Attacker reuses a token issued for Service A on Service B
- Your server doesn't validate the
aud(audience) claim - Result: Privilege escalation across services
Attack #5: Token Reuse After Revocation
- User disconnects their account (revokes authorization)
- Your server doesn't check a revocation blacklist
- Result: Continued unauthorized access
These aren't theoretical. Real-world ChatGPT apps have been rejected during OpenAI review for these exact vulnerabilities.
Token Security vs. Traditional Session Security
Traditional web apps use server-side sessions:
User Login → Server creates session → Session ID in cookie → Server validates session ID
ChatGPT apps use stateless token authentication:
OAuth Flow → Authorization server issues JWT → Client sends JWT with requests → Server validates JWT signature & claims
The difference: Sessions are stored server-side (easy to invalidate). JWTs are self-contained (harder to revoke). This makes token security critically important—you can't just "delete a session" to revoke access.
For more on OAuth 2.1 fundamentals, see our OAuth 2.1 for ChatGPT Apps Complete Guide.
JWT Validation: Signature Verification & Claim Checks {#jwt-validation}
Understanding JWT Structure
A JWT (JSON Web Token) has three parts:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
[--- Header ---].[--- Payload ---].[--- Signature ---]
Header: Algorithm and token type
{
"alg": "RS256",
"typ": "JWT"
}
Payload: Claims (user data, expiration, issuer)
{
"sub": "user_12345",
"iss": "https://chatgpt.com",
"aud": "https://api.yourapp.com",
"exp": 1735171200,
"iat": 1735167600,
"scope": "read:profile write:bookings"
}
Signature: Cryptographic proof the token wasn't tampered with
The signature is created by:
- Base64-encoding the header and payload
- Concatenating with a period:
header.payload - Signing with the authorization server's private key
Your server validates by verifying the signature using the server's public key.
Production-Ready JWT Validator
Here's a complete JWT validator that implements all required security checks:
// jwt-validator.ts
import * as jose from 'jose';
interface JWTValidatorConfig {
issuer: string; // Expected issuer (e.g., 'https://chatgpt.com')
audience: string; // Expected audience (your API URL)
jwksUri: string; // URL to fetch public keys (JWKS endpoint)
clockTolerance?: number; // Allowed time skew in seconds (default: 30)
requiredClaims?: string[]; // Additional required claims
}
interface ValidatedToken {
sub: string; // Subject (user ID)
scope: string; // OAuth scopes
exp: number; // Expiration timestamp
iat: number; // Issued at timestamp
[key: string]: any; // Additional claims
}
export class JWTValidator {
private config: JWTValidatorConfig;
private jwksCache: Map<string, jose.KeyLike> = new Map();
private jwksCacheExpiry: number = 0;
constructor(config: JWTValidatorConfig) {
this.config = {
clockTolerance: 30,
requiredClaims: [],
...config
};
}
/**
* Validates a JWT access token
* Returns validated token claims on success, throws on failure
*/
async validateAccessToken(token: string): Promise<ValidatedToken> {
try {
// Step 1: Fetch JWKS (JSON Web Key Set) from authorization server
const publicKey = await this.getPublicKey(token);
// Step 2: Verify signature and decode claims
const { payload } = await jose.jwtVerify(token, publicKey, {
issuer: this.config.issuer,
audience: this.config.audience,
clockTolerance: this.config.clockTolerance,
});
// Step 3: Validate required claims exist
this.validateRequiredClaims(payload);
// Step 4: Validate token hasn't been revoked (check blacklist)
await this.checkRevocationList(payload.jti as string);
// Step 5: Validate scopes (if your app requires specific scopes)
this.validateScopes(payload.scope as string);
return payload as ValidatedToken;
} catch (error) {
if (error instanceof jose.errors.JWTExpired) {
throw new TokenValidationError('Token expired', 'TOKEN_EXPIRED');
}
if (error instanceof jose.errors.JWTClaimValidationFailed) {
throw new TokenValidationError('Invalid token claims', 'INVALID_CLAIMS');
}
if (error instanceof jose.errors.JWSSignatureVerificationFailed) {
throw new TokenValidationError('Invalid signature', 'INVALID_SIGNATURE');
}
throw new TokenValidationError('Token validation failed', 'VALIDATION_FAILED');
}
}
/**
* Fetches public key from JWKS endpoint (with caching)
*/
private async getPublicKey(token: string): Promise<jose.KeyLike> {
// Cache JWKS for 1 hour to avoid repeated fetches
if (Date.now() < this.jwksCacheExpiry && this.jwksCache.size > 0) {
const header = jose.decodeProtectedHeader(token);
const cachedKey = this.jwksCache.get(header.kid || 'default');
if (cachedKey) return cachedKey;
}
// Fetch JWKS from authorization server
const JWKS = jose.createRemoteJWKSet(new URL(this.config.jwksUri));
const publicKey = await JWKS(
jose.decodeProtectedHeader(token),
token
);
// Cache for 1 hour
this.jwksCacheExpiry = Date.now() + 3600000;
this.jwksCache.set(
jose.decodeProtectedHeader(token).kid || 'default',
publicKey
);
return publicKey;
}
/**
* Validates all required claims are present
*/
private validateRequiredClaims(payload: jose.JWTPayload): void {
const requiredClaims = ['sub', 'exp', 'iat', ...this.config.requiredClaims || []];
for (const claim of requiredClaims) {
if (!(claim in payload)) {
throw new TokenValidationError(
`Missing required claim: ${claim}`,
'MISSING_CLAIM'
);
}
}
}
/**
* Checks if token has been revoked (implement with Redis)
*/
private async checkRevocationList(jti?: string): Promise<void> {
if (!jti) return; // JTI (JWT ID) is optional but recommended
// Check Redis blacklist (implementation in Token Revocation section)
const isRevoked = await this.isTokenRevoked(jti);
if (isRevoked) {
throw new TokenValidationError('Token has been revoked', 'TOKEN_REVOKED');
}
}
/**
* Validates token has required scopes
*/
private validateScopes(scope: string): void {
// Example: Require 'read:profile' scope for protected routes
const requiredScopes = ['read:profile'];
const tokenScopes = scope.split(' ');
for (const requiredScope of requiredScopes) {
if (!tokenScopes.includes(requiredScope)) {
throw new TokenValidationError(
`Missing required scope: ${requiredScope}`,
'INSUFFICIENT_SCOPE'
);
}
}
}
/**
* Placeholder for revocation check (implemented in next section)
*/
private async isTokenRevoked(jti: string): Promise<boolean> {
// Implement with Redis (see Token Revocation section)
return false;
}
}
export class TokenValidationError extends Error {
constructor(message: string, public code: string) {
super(message);
this.name = 'TokenValidationError';
}
}
Using the JWT Validator in Your MCP Server
// mcp-server.ts
import { JWTValidator } from './jwt-validator';
const jwtValidator = new JWTValidator({
issuer: 'https://chatgpt.com',
audience: 'https://api.yourapp.com',
jwksUri: 'https://chatgpt.com/.well-known/jwks.json',
clockTolerance: 30,
requiredClaims: ['scope']
});
// Middleware for all MCP tool calls
export async function authenticateRequest(request: Request): Promise<ValidatedToken> {
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
throw new Error('Missing authorization header');
}
const token = authHeader.substring(7); // Remove 'Bearer ' prefix
const validatedToken = await jwtValidator.validateAccessToken(token);
return validatedToken;
}
Why This Approach Works:
- Signature verification prevents forgery - Uses public key cryptography
- JWKS caching prevents performance issues - Only fetches keys once per hour
- Clock tolerance prevents false rejections - Allows 30-second time skew
- Claim validation prevents misuse - Checks issuer, audience, expiration
- Revocation check prevents reuse - Integrates with Redis blacklist
For comprehensive security implementation, see our ChatGPT App Security Complete Guide.
Refresh Token Rotation: Automatic Rotation & Reuse Detection {#refresh-token-rotation}
Why Refresh Token Rotation Matters
The Problem: Access tokens expire quickly (typically 15 minutes). Refresh tokens are long-lived (days or weeks). If an attacker steals a refresh token, they can generate new access tokens indefinitely.
The Solution: OAuth 2.1 requires refresh token rotation:
- Every time a refresh token is used, it becomes invalid
- A new refresh token is issued with the new access token
- If an old refresh token is reused, it indicates theft (revoke all tokens)
Production-Ready Refresh Token Rotator
// refresh-token-rotator.ts
import * as crypto from 'crypto';
import { Redis } from 'ioredis';
interface RefreshTokenMetadata {
userId: string;
tokenFamily: string; // Token family ID (tracks rotation chain)
issuedAt: number;
expiresAt: number;
previousToken?: string; // Previous token in rotation chain
deviceId?: string; // Device fingerprint
}
export class RefreshTokenRotator {
private redis: Redis;
private tokenTTL: number = 2592000; // 30 days in seconds
constructor(redisClient: Redis) {
this.redis = redisClient;
}
/**
* Issues a new refresh token and stores metadata
*/
async issueRefreshToken(
userId: string,
deviceId?: string
): Promise<string> {
const tokenFamily = crypto.randomUUID(); // New token family
const refreshToken = this.generateSecureToken();
const metadata: RefreshTokenMetadata = {
userId,
tokenFamily,
issuedAt: Date.now(),
expiresAt: Date.now() + this.tokenTTL * 1000,
deviceId
};
// Store refresh token metadata in Redis
await this.redis.setex(
`refresh_token:${refreshToken}`,
this.tokenTTL,
JSON.stringify(metadata)
);
// Track token family (for reuse detection)
await this.redis.sadd(`token_family:${tokenFamily}`, refreshToken);
await this.redis.expire(`token_family:${tokenFamily}`, this.tokenTTL);
return refreshToken;
}
/**
* Rotates refresh token (creates new token, invalidates old)
*/
async rotateRefreshToken(
currentToken: string
): Promise<{ accessToken: string; refreshToken: string }> {
// Step 1: Validate current refresh token exists
const metadataJson = await this.redis.get(`refresh_token:${currentToken}`);
if (!metadataJson) {
throw new RefreshTokenError('Invalid refresh token', 'INVALID_TOKEN');
}
const metadata: RefreshTokenMetadata = JSON.parse(metadataJson);
// Step 2: Check if token has already been used (reuse detection)
const isAlreadyUsed = await this.redis.get(`used_token:${currentToken}`);
if (isAlreadyUsed) {
// CRITICAL: Token reuse detected - revoke entire token family
await this.revokeTokenFamily(metadata.tokenFamily);
throw new RefreshTokenError(
'Refresh token reuse detected - all tokens revoked',
'TOKEN_REUSE_DETECTED'
);
}
// Step 3: Check expiration
if (Date.now() > metadata.expiresAt) {
throw new RefreshTokenError('Refresh token expired', 'TOKEN_EXPIRED');
}
// Step 4: Mark current token as used (prevent reuse)
await this.redis.setex(
`used_token:${currentToken}`,
this.tokenTTL,
'1'
);
// Step 5: Generate new refresh token
const newRefreshToken = this.generateSecureToken();
const newMetadata: RefreshTokenMetadata = {
...metadata,
issuedAt: Date.now(),
expiresAt: Date.now() + this.tokenTTL * 1000,
previousToken: currentToken
};
// Step 6: Store new refresh token
await this.redis.setex(
`refresh_token:${newRefreshToken}`,
this.tokenTTL,
JSON.stringify(newMetadata)
);
// Step 7: Add to token family
await this.redis.sadd(`token_family:${metadata.tokenFamily}`, newRefreshToken);
// Step 8: Delete old refresh token
await this.redis.del(`refresh_token:${currentToken}`);
// Step 9: Generate new access token
const accessToken = await this.generateAccessToken(metadata.userId);
return {
accessToken,
refreshToken: newRefreshToken
};
}
/**
* Revokes an entire token family (used when reuse is detected)
*/
private async revokeTokenFamily(tokenFamily: string): Promise<void> {
// Get all tokens in the family
const tokens = await this.redis.smembers(`token_family:${tokenFamily}`);
// Delete all tokens
const pipeline = this.redis.pipeline();
for (const token of tokens) {
pipeline.del(`refresh_token:${token}`);
pipeline.setex(`revoked_token:${token}`, this.tokenTTL, '1');
}
pipeline.del(`token_family:${tokenFamily}`);
await pipeline.exec();
console.error(`[SECURITY] Token family ${tokenFamily} revoked due to reuse detection`);
}
/**
* Generates cryptographically secure token
*/
private generateSecureToken(): string {
return crypto.randomBytes(32).toString('base64url');
}
/**
* Generates new access token (placeholder - implement JWT signing)
*/
private async generateAccessToken(userId: string): Promise<string> {
// Implement JWT signing (use jose library)
// Return signed JWT with 15-minute expiration
return 'access_token_placeholder';
}
}
export class RefreshTokenError extends Error {
constructor(message: string, public code: string) {
super(message);
this.name = 'RefreshTokenError';
}
}
Implementing Refresh Token Rotation in Your OAuth Endpoint
// oauth-endpoints.ts
import { RefreshTokenRotator } from './refresh-token-rotator';
import { Redis } from 'ioredis';
const redis = new Redis({ host: 'localhost', port: 6379 });
const tokenRotator = new RefreshTokenRotator(redis);
// POST /oauth/token
export async function handleTokenRefresh(request: Request): Promise<Response> {
const body = await request.formData();
const grantType = body.get('grant_type');
const refreshToken = body.get('refresh_token');
if (grantType !== 'refresh_token' || !refreshToken) {
return new Response(JSON.stringify({ error: 'invalid_request' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
try {
const { accessToken, refreshToken: newRefreshToken } =
await tokenRotator.rotateRefreshToken(refreshToken as string);
return new Response(JSON.stringify({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 900, // 15 minutes
refresh_token: newRefreshToken
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (error) {
if (error instanceof RefreshTokenError) {
if (error.code === 'TOKEN_REUSE_DETECTED') {
// Log security incident
console.error('[SECURITY] Refresh token reuse detected:', error.message);
return new Response(JSON.stringify({
error: 'invalid_grant',
error_description: 'Token reuse detected - all sessions revoked'
}), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
}
return new Response(JSON.stringify({ error: 'invalid_grant' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
}
Security Benefits:
- Automatic rotation prevents long-term token theft - Each use generates new token
- Reuse detection identifies attacks - Logs security incidents
- Token family revocation limits damage - Revokes all related tokens
- Redis storage enables fast validation - Sub-millisecond lookups
- Cryptographically secure tokens - 256-bit entropy
Token Revocation: Blacklist Patterns & Redis Integration {#token-revocation}
The Token Revocation Challenge
JWTs are stateless - they contain all necessary information and don't require database lookups. This creates a problem: how do you revoke a JWT before it expires naturally?
Scenarios requiring revocation:
- User logs out
- User disconnects ChatGPT app authorization
- Admin detects suspicious activity
- User changes password
- Security breach requires mass revocation
Production-Ready Token Revocation Service
// token-revocation.ts
import { Redis } from 'ioredis';
export class TokenRevocationService {
private redis: Redis;
constructor(redisClient: Redis) {
this.redis = redisClient;
}
/**
* Revokes a single access token
*/
async revokeAccessToken(jti: string, expiresAt: number): Promise<void> {
const ttl = Math.max(0, Math.ceil((expiresAt - Date.now()) / 1000));
if (ttl > 0) {
await this.redis.setex(`revoked_access:${jti}`, ttl, '1');
}
}
/**
* Revokes all tokens for a user
*/
async revokeAllUserTokens(userId: string): Promise<void> {
// Set a revocation timestamp - any token issued before this is invalid
await this.redis.set(`user_revoke_all:${userId}`, Date.now().toString());
// Expire after 24 hours (assumes max token lifetime is 24h)
await this.redis.expire(`user_revoke_all:${userId}`, 86400);
}
/**
* Checks if an access token has been revoked
*/
async isAccessTokenRevoked(jti: string, userId: string, issuedAt: number): Promise<boolean> {
// Check if this specific token was revoked
const isTokenRevoked = await this.redis.exists(`revoked_access:${jti}`);
if (isTokenRevoked) return true;
// Check if all user tokens were revoked after this token was issued
const userRevokeTimestamp = await this.redis.get(`user_revoke_all:${userId}`);
if (userRevokeTimestamp) {
const revokeTime = parseInt(userRevokeTimestamp, 10);
if (issuedAt < revokeTime) {
return true; // Token was issued before revocation
}
}
return false;
}
/**
* Revokes a refresh token
*/
async revokeRefreshToken(refreshToken: string): Promise<void> {
await this.redis.setex(`revoked_refresh:${refreshToken}`, 2592000, '1'); // 30 days
}
/**
* Checks if a refresh token has been revoked
*/
async isRefreshTokenRevoked(refreshToken: string): Promise<boolean> {
return Boolean(await this.redis.exists(`revoked_refresh:${refreshToken}`));
}
}
Integrating Revocation with JWT Validator
Update the JWTValidator class from earlier:
// jwt-validator.ts (updated)
export class JWTValidator {
private revocationService: TokenRevocationService;
constructor(config: JWTValidatorConfig, revocationService: TokenRevocationService) {
this.config = config;
this.revocationService = revocationService;
}
private async checkRevocationList(jti: string, userId: string, issuedAt: number): Promise<void> {
const isRevoked = await this.revocationService.isAccessTokenRevoked(
jti,
userId,
issuedAt
);
if (isRevoked) {
throw new TokenValidationError('Token has been revoked', 'TOKEN_REVOKED');
}
}
}
Revocation Strategies:
- JTI Blacklist - Revoke specific tokens (lowest overhead)
- User-Level Revocation - Revoke all tokens for a user (security incidents)
- TTL Optimization - Only store revocations until token expiration
- Batch Revocation - Use Redis pipelines for mass revocation
For comprehensive security auditing, see our Security Auditing & Logging Guide.
Claim Validation: Issuer, Audience, Expiration {#claim-validation}
The Six Critical JWT Claims
OAuth 2.1 access tokens MUST include these claims:
1. iss (Issuer) - Who created the token
{ "iss": "https://chatgpt.com" }
Validation: Must match your expected issuer exactly. Prevents cross-service token misuse.
2. aud (Audience) - Who the token is intended for
{ "aud": "https://api.yourapp.com" }
Validation: Must match your API URL. Prevents token reuse on different services.
3. exp (Expiration) - When the token expires
{ "exp": 1735171200 }
Validation: Must be in the future. Prevents expired token reuse.
4. iat (Issued At) - When the token was created
{ "iat": 1735167600 }
Validation: Should be in the past (with clock tolerance). Detects clock skew attacks.
5. sub (Subject) - User identifier
{ "sub": "user_12345" }
Validation: Must exist and match expected format.
6. jti (JWT ID) - Unique token identifier
{ "jti": "550e8400-e29b-41d4-a716-446655440000" }
Validation: Optional but recommended for revocation.
Production-Ready Claim Validator
// claim-validator.ts
interface ClaimValidationRules {
issuer: string | string[]; // Allowed issuers
audience: string | string[]; // Allowed audiences
maxAge?: number; // Max token age in seconds
clockTolerance?: number; // Allowed clock skew
requiredClaims?: string[]; // Additional required claims
}
export class ClaimValidator {
private rules: ClaimValidationRules;
constructor(rules: ClaimValidationRules) {
this.rules = {
clockTolerance: 30,
...rules
};
}
/**
* Validates all JWT claims according to OAuth 2.1 requirements
*/
validate(claims: Record<string, any>): void {
this.validateIssuer(claims.iss);
this.validateAudience(claims.aud);
this.validateExpiration(claims.exp);
this.validateIssuedAt(claims.iat);
this.validateSubject(claims.sub);
this.validateMaxAge(claims.iat);
this.validateRequiredClaims(claims);
}
private validateIssuer(iss: string): void {
const allowedIssuers = Array.isArray(this.rules.issuer)
? this.rules.issuer
: [this.rules.issuer];
if (!allowedIssuers.includes(iss)) {
throw new ClaimValidationError(
`Invalid issuer: ${iss}. Expected: ${allowedIssuers.join(', ')}`,
'INVALID_ISSUER'
);
}
}
private validateAudience(aud: string | string[]): void {
const tokenAudiences = Array.isArray(aud) ? aud : [aud];
const allowedAudiences = Array.isArray(this.rules.audience)
? this.rules.audience
: [this.rules.audience];
const hasValidAudience = tokenAudiences.some(a =>
allowedAudiences.includes(a)
);
if (!hasValidAudience) {
throw new ClaimValidationError(
`Invalid audience. Expected: ${allowedAudiences.join(', ')}`,
'INVALID_AUDIENCE'
);
}
}
private validateExpiration(exp: number): void {
const now = Math.floor(Date.now() / 1000);
const clockTolerance = this.rules.clockTolerance || 0;
if (exp < now - clockTolerance) {
throw new ClaimValidationError(
`Token expired at ${new Date(exp * 1000).toISOString()}`,
'TOKEN_EXPIRED'
);
}
}
private validateIssuedAt(iat: number): void {
const now = Math.floor(Date.now() / 1000);
const clockTolerance = this.rules.clockTolerance || 0;
if (iat > now + clockTolerance) {
throw new ClaimValidationError(
'Token issued in the future (clock skew detected)',
'INVALID_ISSUED_AT'
);
}
}
private validateSubject(sub: string): void {
if (!sub || typeof sub !== 'string' || sub.trim() === '') {
throw new ClaimValidationError(
'Missing or invalid subject (sub) claim',
'INVALID_SUBJECT'
);
}
}
private validateMaxAge(iat: number): void {
if (!this.rules.maxAge) return;
const now = Math.floor(Date.now() / 1000);
const tokenAge = now - iat;
if (tokenAge > this.rules.maxAge) {
throw new ClaimValidationError(
`Token too old. Age: ${tokenAge}s, Max: ${this.rules.maxAge}s`,
'TOKEN_TOO_OLD'
);
}
}
private validateRequiredClaims(claims: Record<string, any>): void {
const requiredClaims = this.rules.requiredClaims || [];
for (const claim of requiredClaims) {
if (!(claim in claims)) {
throw new ClaimValidationError(
`Missing required claim: ${claim}`,
'MISSING_CLAIM'
);
}
}
}
}
export class ClaimValidationError extends Error {
constructor(message: string, public code: string) {
super(message);
this.name = 'ClaimValidationError';
}
}
Token Storage: Secure Storage & httpOnly Cookies {#token-storage}
Where NOT to Store Tokens
❌ LocalStorage - Vulnerable to XSS attacks
// NEVER DO THIS
localStorage.setItem('access_token', token);
❌ SessionStorage - Same XSS vulnerability as localStorage
❌ JavaScript-accessible cookies - XSS can read these
❌ URL parameters - Logged in server logs, browser history
Secure Token Storage Implementation
// secure-token-storage.ts
export class SecureTokenStorage {
/**
* Stores access token in httpOnly cookie (server-side only)
*/
static setAccessToken(response: Response, token: string, expiresIn: number): void {
const cookie = [
`access_token=${token}`,
'HttpOnly', // Prevents JavaScript access
'Secure', // Only sent over HTTPS
'SameSite=Strict', // CSRF protection
`Max-Age=${expiresIn}`,
'Path=/'
].join('; ');
response.headers.append('Set-Cookie', cookie);
}
/**
* Stores refresh token in httpOnly cookie (long-lived)
*/
static setRefreshToken(response: Response, token: string): void {
const cookie = [
`refresh_token=${token}`,
'HttpOnly',
'Secure',
'SameSite=Strict',
'Max-Age=2592000', // 30 days
'Path=/oauth/token' // Only sent to refresh endpoint
].join('; ');
response.headers.append('Set-Cookie', cookie);
}
/**
* Clears all tokens (logout)
*/
static clearTokens(response: Response): void {
response.headers.append('Set-Cookie', 'access_token=; Max-Age=0; Path=/');
response.headers.append('Set-Cookie', 'refresh_token=; Max-Age=0; Path=/');
}
}
Security Benefits:
- HttpOnly prevents XSS theft - JavaScript cannot read cookies
- Secure flag prevents MITM - Only sent over HTTPS
- SameSite prevents CSRF - Cookies not sent with cross-site requests
- Path restriction limits exposure - Refresh token only sent to refresh endpoint
For more on OAuth security patterns, see our OAuth Security Auditing & Compliance Guide.
Production Checklist & Next Steps {#production-checklist}
Token Security Checklist
Before deploying your ChatGPT app, verify:
JWT Validation
- ✅ Signature verification using JWKS endpoint
- ✅ Issuer claim matches expected value
- ✅ Audience claim matches your API URL
- ✅ Expiration claim checked with clock tolerance
- ✅ JTI claim validated against revocation list
Refresh Token Rotation
- ✅ New refresh token issued on every use
- ✅ Old refresh token immediately invalidated
- ✅ Reuse detection triggers family revocation
- ✅ Token families tracked in Redis
- ✅ Security incidents logged
Token Revocation
- ✅ Redis blacklist for revoked tokens
- ✅ TTL matches token expiration
- ✅ User-level revocation supported
- ✅ Logout revokes all user tokens
Claim Validation
- ✅ All required claims present
- ✅ Clock tolerance prevents false rejections
- ✅ Max token age enforced
- ✅ Subject claim validated
Token Storage
- ✅ httpOnly cookies for access tokens
- ✅ Secure flag enabled (HTTPS only)
- ✅ SameSite=Strict for CSRF protection
- ✅ Path restrictions on refresh tokens
Next Steps
1. Implement Token Introspection Learn how to validate tokens with authorization server introspection endpoints.
2. Add Token Audit Logging Track all token operations for security compliance. See our Security Auditing Guide.
3. Implement PKCE for OAuth Flow Complete your OAuth 2.1 implementation with PKCE. See our OAuth 2.1 Complete Guide.
4. Run Penetration Tests Validate your token security with professional penetration testing. See our Penetration Testing Guide.
5. Deploy to Production Use MakeAIHQ.com to build ChatGPT apps with enterprise-grade token security built-in—no coding required.
Build Secure ChatGPT Apps Without Writing Authentication Code
Implementing production-ready token security requires 600+ lines of TypeScript, Redis infrastructure, and deep OAuth 2.1 expertise. MakeAIHQ.com generates all this code automatically with our AI-powered ChatGPT app builder.
What MakeAIHQ Provides:
✅ JWT validation with signature verification - JWKS integration built-in ✅ Automatic refresh token rotation - Reuse detection included ✅ Redis-based token revocation - One-click blacklist management ✅ Claim validation - Issuer, audience, expiration checks ✅ Secure token storage - httpOnly cookies configured ✅ OAuth 2.1 compliance - Pass OpenAI approval on first submission
Get Started in 3 Steps:
- Describe your app - "Fitness studio booking system"
- Review generated code - OAuth 2.1, JWT validation, token rotation
- Deploy to ChatGPT - One-click submission to App Store
Ready to build secure ChatGPT apps?
👉 Start Free Trial - Create your first ChatGPT app with production-ready token security in 48 hours.
Related Resources:
- ChatGPT App Security Complete Guide - OWASP vulnerabilities, compliance, security audits
- OAuth 2.1 for ChatGPT Apps - Complete authentication implementation
- OAuth Security Auditing - Compliance requirements
- Security Testing Beyond Pentesting - Advanced security validation
- Penetration Testing ChatGPT Apps - Professional security assessments
External Resources:
- RFC 9068: JWT Profile for OAuth 2.0 Access Tokens - Official JWT specification
- OAuth 2.1 Draft Specification - Latest OAuth standard
- OWASP JWT Security Cheat Sheet - Best practices guide