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

  1. Why Token Security Prevents Unauthorized ChatGPT Access
  2. JWT Validation: Signature Verification & Claim Checks
  3. Refresh Token Rotation: Automatic Rotation & Reuse Detection
  4. Token Revocation: Blacklist Patterns & Redis Integration
  5. Claim Validation: Issuer, Audience, Expiration
  6. Token Storage: Secure Storage & httpOnly Cookies
  7. 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:

  1. Base64-encoding the header and payload
  2. Concatenating with a period: header.payload
  3. 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:

  1. Signature verification prevents forgery - Uses public key cryptography
  2. JWKS caching prevents performance issues - Only fetches keys once per hour
  3. Clock tolerance prevents false rejections - Allows 30-second time skew
  4. Claim validation prevents misuse - Checks issuer, audience, expiration
  5. 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:

  1. Automatic rotation prevents long-term token theft - Each use generates new token
  2. Reuse detection identifies attacks - Logs security incidents
  3. Token family revocation limits damage - Revokes all related tokens
  4. Redis storage enables fast validation - Sub-millisecond lookups
  5. 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:

  1. JTI Blacklist - Revoke specific tokens (lowest overhead)
  2. User-Level Revocation - Revoke all tokens for a user (security incidents)
  3. TTL Optimization - Only store revocations until token expiration
  4. 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:

  1. HttpOnly prevents XSS theft - JavaScript cannot read cookies
  2. Secure flag prevents MITM - Only sent over HTTPS
  3. SameSite prevents CSRF - Cookies not sent with cross-site requests
  4. 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:

  1. Describe your app - "Fitness studio booking system"
  2. Review generated code - OAuth 2.1, JWT validation, token rotation
  3. 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:

External Resources: