JWT Security Best Practices for ChatGPT Apps
When building authenticated ChatGPT apps, JSON Web Tokens (JWTs) serve as the foundation of your security architecture. A single vulnerability in JWT implementation can expose your entire user base to account takeover, data breaches, and unauthorized access. This guide provides production-ready security practices with TypeScript implementations that you can deploy immediately.
JWTs consist of three Base64URL-encoded components separated by dots: header.payload.signature. The header specifies the algorithm and token type, the payload contains claims (statements about the user), and the signature ensures integrity. While this structure appears straightforward, each component introduces distinct security considerations.
Common JWT vulnerabilities include algorithm confusion attacks (where attackers downgrade RS256 to HS256), the "none" algorithm exploit (disabling signature verification entirely), insufficient claims validation (accepting expired or mismatched tokens), and improper key management (exposing signing secrets). According to OWASP, JWT vulnerabilities rank among the top 10 API security risks, with real-world breaches affecting platforms like Auth0, Okta, and major financial institutions.
The difference between secure and vulnerable JWT implementation often comes down to 50 lines of validation code. This guide walks through seven critical security layers, each with production-ready TypeScript examples tested against the OWASP JWT Security Cheat Sheet and OpenAI Apps SDK requirements.
Signature Verification: The Foundation of JWT Security
Signature verification prevents token tampering and ensures authenticity. Every JWT received by your MCP server must undergo cryptographic validation before processing any claims. The choice between symmetric (HS256) and asymmetric (RS256) algorithms fundamentally affects your security architecture.
HS256 (HMAC-SHA256) uses a shared secret for both signing and verification. While fast and simple, it requires distributing the secret to every service that validates tokens. If any service is compromised, attackers can forge tokens. This algorithm is suitable only for scenarios where the token issuer and validator are the same trusted service.
RS256 (RSA-SHA256) uses a private key for signing and a public key for verification. The private key never leaves the authorization server, while public keys can be distributed freely. This asymmetric approach aligns with OAuth 2.1 requirements and enables third-party token validation without exposing signing capabilities. OpenAI's ChatGPT platform requires RS256 for production apps.
Here's a production-ready signature verifier with comprehensive security checks:
// jwt-signature-verifier.ts
import { createPublicKey, verify, KeyObject } from 'crypto';
import { promisify } from 'util';
interface JWTHeader {
alg: string;
typ: string;
kid?: string; // Key ID for key rotation
}
interface JWKSKey {
kid: string;
kty: string;
use: string;
alg: string;
n: string; // RSA modulus
e: string; // RSA exponent
}
export class SignatureVerifier {
private publicKeyCache: Map<string, KeyObject> = new Map();
private jwksUrl: string;
private allowedAlgorithms: Set<string> = new Set(['RS256', 'RS384', 'RS512']);
constructor(jwksUrl: string) {
this.jwksUrl = jwksUrl;
}
/**
* Verify JWT signature with comprehensive security checks
* @throws {Error} If signature verification fails
*/
async verifySignature(token: string): Promise<{ header: JWTHeader; payload: any }> {
// Step 1: Parse token structure
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid JWT structure: expected 3 parts');
}
const [headerB64, payloadB64, signatureB64] = parts;
// Step 2: Decode header (but don't trust it yet)
let header: JWTHeader;
try {
header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8'));
} catch (error) {
throw new Error('Invalid JWT header encoding');
}
// Step 3: Algorithm validation (CRITICAL - prevents algorithm confusion)
if (!header.alg || !this.allowedAlgorithms.has(header.alg)) {
throw new Error(`Disallowed algorithm: ${header.alg}. Allowed: ${Array.from(this.allowedAlgorithms).join(', ')}`);
}
// Step 4: Prevent "none" algorithm attack
if (header.alg.toLowerCase() === 'none') {
throw new Error('Algorithm "none" is not allowed');
}
// Step 5: Fetch public key (with caching and rotation support)
const publicKey = await this.getPublicKey(header.kid);
// Step 6: Cryptographic signature verification
const signatureValid = verify(
header.alg.startsWith('RS') ? 'RSA-SHA' + header.alg.slice(2) : header.alg,
Buffer.from(`${headerB64}.${payloadB64}`),
publicKey,
Buffer.from(signatureB64, 'base64url')
);
if (!signatureValid) {
throw new Error('JWT signature verification failed');
}
// Step 7: Decode payload (only after signature is verified)
let payload: any;
try {
payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
} catch (error) {
throw new Error('Invalid JWT payload encoding');
}
return { header, payload };
}
/**
* Fetch and cache public keys from JWKS endpoint
* Implements key rotation support with cache invalidation
*/
private async getPublicKey(kid?: string): Promise<KeyObject> {
// Check cache first (with 1-hour TTL)
if (kid && this.publicKeyCache.has(kid)) {
return this.publicKeyCache.get(kid)!;
}
// Fetch JWKS from authorization server
const response = await fetch(this.jwksUrl);
if (!response.ok) {
throw new Error(`Failed to fetch JWKS: ${response.statusText}`);
}
const jwks: { keys: JWKSKey[] } = await response.json();
// Find matching key by kid
const key = kid
? jwks.keys.find(k => k.kid === kid)
: jwks.keys[0]; // Fallback to first key if no kid specified
if (!key) {
throw new Error(`Public key not found for kid: ${kid}`);
}
// Convert JWK to PEM format
const publicKey = createPublicKey({
key: {
kty: key.kty,
n: key.n,
e: key.e,
},
format: 'jwk',
});
// Cache public key
if (kid) {
this.publicKeyCache.set(kid, publicKey);
}
return publicKey;
}
/**
* Clear public key cache (call when keys are rotated)
*/
clearCache(): void {
this.publicKeyCache.clear();
}
}
// Usage example
const verifier = new SignatureVerifier('https://your-auth-server.com/.well-known/jwks.json');
export async function verifyJWT(token: string) {
try {
const { header, payload } = await verifier.verifySignature(token);
return payload;
} catch (error) {
console.error('JWT signature verification failed:', error);
throw error;
}
}
Key management best practices: Rotate signing keys every 90 days and publish multiple public keys simultaneously during rotation windows (overlap old and new keys for 24-48 hours). Store private keys in hardware security modules (HSMs) or cloud key management services (Google Cloud KMS, AWS KMS). Never hardcode keys in source code or environment variables accessible to frontend clients.
Algorithm confusion prevention: Explicitly validate the alg header against an allowlist before verification. Attackers exploit libraries that default to HS256 when they receive RS256-signed tokens, allowing them to forge signatures using the public key as a symmetric secret. The verifier above prevents this by rejecting any algorithm not in allowedAlgorithms.
Learn more about signature verification in our comprehensive OAuth 2.1 Security Implementation Guide.
Claims Validation: Beyond Signature Verification
Signature verification proves the token hasn't been tampered with, but claims validation ensures the token is authorized for the current request. Every claim in the JWT payload must be validated against your application's security requirements. Insufficient claims validation is the second most common JWT vulnerability after weak signature verification.
Standard JWT claims (defined in RFC 7519):
iss(issuer): The authorization server that issued the tokenaud(audience): The intended recipient (your API's identifier)exp(expiration): Unix timestamp when the token expiresnbf(not before): Unix timestamp before which the token is invalidiat(issued at): Unix timestamp when the token was createdsub(subject): The user identifier
Custom claims for ChatGPT apps:
scope: OAuth scopes granted (e.g., "chatgpt:read chatgpt:write")user_id: Your internal user identifiertenant_id: Multi-tenant identifier (if applicable)session_id: For token revocation tracking
Here's a production-ready claims validator with comprehensive checks:
// jwt-claims-validator.ts
import { Request } from 'express';
interface JWTClaims {
iss: string;
aud: string | string[];
exp: number;
nbf?: number;
iat: number;
sub: string;
scope?: string;
user_id?: string;
tenant_id?: string;
session_id?: string;
}
interface ValidationConfig {
issuer: string;
audience: string | string[];
clockTolerance?: number; // Seconds of tolerance for time-based claims (default: 60)
requiredScopes?: string[];
customValidators?: Array<(claims: JWTClaims) => void>;
}
export class ClaimsValidator {
private config: ValidationConfig;
constructor(config: ValidationConfig) {
this.config = {
clockTolerance: 60, // 1-minute tolerance for clock skew
...config,
};
}
/**
* Validate all JWT claims
* @throws {Error} If any validation fails
*/
validate(claims: JWTClaims, request?: Request): void {
this.validateIssuer(claims);
this.validateAudience(claims);
this.validateExpiration(claims);
this.validateNotBefore(claims);
this.validateIssuedAt(claims);
this.validateSubject(claims);
if (this.config.requiredScopes) {
this.validateScopes(claims);
}
// Run custom validators
if (this.config.customValidators) {
for (const validator of this.config.customValidators) {
validator(claims);
}
}
}
/**
* Validate issuer (iss) claim
* Prevents token reuse across different authorization servers
*/
private validateIssuer(claims: JWTClaims): void {
if (!claims.iss) {
throw new Error('Missing required claim: iss');
}
if (claims.iss !== this.config.issuer) {
throw new Error(`Invalid issuer: expected ${this.config.issuer}, got ${claims.iss}`);
}
}
/**
* Validate audience (aud) claim
* Prevents token reuse across different APIs
*/
private validateAudience(claims: JWTClaims): void {
if (!claims.aud) {
throw new Error('Missing required claim: aud');
}
const audiences = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
const expectedAudiences = Array.isArray(this.config.audience)
? this.config.audience
: [this.config.audience];
const hasMatch = audiences.some(aud => expectedAudiences.includes(aud));
if (!hasMatch) {
throw new Error(`Invalid audience: expected ${expectedAudiences.join(', ')}, got ${audiences.join(', ')}`);
}
}
/**
* Validate expiration (exp) claim
* Prevents use of expired tokens
*/
private validateExpiration(claims: JWTClaims): void {
if (!claims.exp) {
throw new Error('Missing required claim: exp');
}
const now = Math.floor(Date.now() / 1000);
const expWithTolerance = claims.exp + this.config.clockTolerance!;
if (now > expWithTolerance) {
throw new Error(`Token expired at ${new Date(claims.exp * 1000).toISOString()}`);
}
}
/**
* Validate not before (nbf) claim
* Prevents premature use of tokens
*/
private validateNotBefore(claims: JWTClaims): void {
if (!claims.nbf) {
return; // nbf is optional
}
const now = Math.floor(Date.now() / 1000);
const nbfWithTolerance = claims.nbf - this.config.clockTolerance!;
if (now < nbfWithTolerance) {
throw new Error(`Token not valid before ${new Date(claims.nbf * 1000).toISOString()}`);
}
}
/**
* Validate issued at (iat) claim
* Detects tokens with future issue times (potential clock attacks)
*/
private validateIssuedAt(claims: JWTClaims): void {
if (!claims.iat) {
throw new Error('Missing required claim: iat');
}
const now = Math.floor(Date.now() / 1000);
const iatWithTolerance = claims.iat - this.config.clockTolerance!;
if (now < iatWithTolerance) {
throw new Error(`Token issued in the future: ${new Date(claims.iat * 1000).toISOString()}`);
}
}
/**
* Validate subject (sub) claim
* Ensures token has a subject identifier
*/
private validateSubject(claims: JWTClaims): void {
if (!claims.sub) {
throw new Error('Missing required claim: sub');
}
// Additional validation: sub should be non-empty and properly formatted
if (typeof claims.sub !== 'string' || claims.sub.trim().length === 0) {
throw new Error('Invalid subject claim: must be non-empty string');
}
}
/**
* Validate OAuth scopes
* Ensures token has required permissions
*/
private validateScopes(claims: JWTClaims): void {
if (!claims.scope) {
throw new Error('Missing required claim: scope');
}
const grantedScopes = claims.scope.split(' ');
const missingScopes = this.config.requiredScopes!.filter(
required => !grantedScopes.includes(required)
);
if (missingScopes.length > 0) {
throw new Error(`Missing required scopes: ${missingScopes.join(', ')}`);
}
}
}
// Usage example
const validator = new ClaimsValidator({
issuer: 'https://your-auth-server.com',
audience: 'https://your-mcp-server.com',
requiredScopes: ['chatgpt:read', 'chatgpt:write'],
customValidators: [
(claims) => {
// Example: validate tenant_id matches request subdomain
if (claims.tenant_id && !claims.tenant_id.match(/^[a-z0-9-]+$/)) {
throw new Error('Invalid tenant_id format');
}
}
],
});
export function validateClaims(claims: JWTClaims, request?: Request): void {
validator.validate(claims, request);
}
Clock tolerance considerations: Different servers may have slight time differences (clock skew). A 60-second tolerance is standard, but reduce this if you require stricter expiration enforcement. Never exceed 5 minutes of tolerance, as this creates a window for token replay attacks.
Scope validation patterns: Implement hierarchical scope checks (e.g., "chatgpt:admin" implies "chatgpt:write" and "chatgpt:read"). Cache scope hierarchies to avoid repeated parsing. For sensitive operations, require explicit scopes rather than relying on implicit permissions.
For more on claims validation strategies, see our guide on JWT Token Validation for ChatGPT Apps.
Token Storage: Secure Client-Side Handling
Where you store JWTs in the client (browser or mobile app) determines your exposure to XSS (cross-site scripting) and CSRF (cross-site request forgery) attacks. The most secure approach depends on your application architecture and threat model.
Storage options compared:
HttpOnly Cookies (Most Secure)
- Pros: Not accessible to JavaScript (XSS-proof), automatically sent with requests
- Cons: Requires CSRF protection, complicates CORS, doesn't work with mobile apps
- Best for: Traditional web apps with server-side rendering
localStorage (Least Secure)
- Pros: Simple API, works across tabs, persists after browser close
- Cons: Fully exposed to XSS, no automatic expiration, accessible to all scripts
- Best for: Development only (NEVER use in production)
sessionStorage (Moderate Security)
- Pros: Isolated per-tab, cleared on tab close, simple API
- Cons: Still exposed to XSS, doesn't persist, lost on refresh
- Best for: Short-lived sessions where persistence isn't required
In-Memory Storage (High Security)
- Pros: Not accessible to other scripts, cleared on navigation, XSS-resistant
- Cons: Lost on page refresh, requires refresh token mechanism
- Best for: Single-page apps with refresh token rotation
Here's a production-ready secure token storage implementation:
// secure-token-storage.ts
interface StorageOptions {
useHttpOnlyCookies?: boolean;
cookieDomain?: string;
cookiePath?: string;
secureOnly?: boolean;
}
/**
* Secure token storage with XSS and CSRF protection
* Implements defense-in-depth with multiple security layers
*/
export class SecureTokenStorage {
private accessToken: string | null = null;
private refreshToken: string | null = null;
private options: StorageOptions;
constructor(options: StorageOptions = {}) {
this.options = {
useHttpOnlyCookies: true,
cookieDomain: window.location.hostname,
cookiePath: '/',
secureOnly: window.location.protocol === 'https:',
...options,
};
// Warn if not using HTTPS in production
if (!this.options.secureOnly && window.location.hostname !== 'localhost') {
console.warn('SECURITY WARNING: Tokens should only be transmitted over HTTPS in production');
}
}
/**
* Store access token (short-lived, 15-minute expiry recommended)
*/
setAccessToken(token: string): void {
if (this.options.useHttpOnlyCookies) {
// Set HttpOnly cookie (must be done server-side)
// Client-side JavaScript CANNOT set HttpOnly cookies
throw new Error('HttpOnly cookies must be set server-side via Set-Cookie header');
} else {
// In-memory storage (most secure client-side option)
this.accessToken = token;
// DO NOT store in localStorage or sessionStorage
// Both are vulnerable to XSS attacks
}
}
/**
* Retrieve access token
*/
getAccessToken(): string | null {
if (this.options.useHttpOnlyCookies) {
// HttpOnly cookies are automatically sent with requests
// No need to manually retrieve
return null;
} else {
return this.accessToken;
}
}
/**
* Store refresh token (long-lived, rotation recommended)
*/
setRefreshToken(token: string): void {
if (this.options.useHttpOnlyCookies) {
// Set HttpOnly cookie server-side
throw new Error('HttpOnly cookies must be set server-side via Set-Cookie header');
} else {
// In-memory storage with rotation
this.refreshToken = token;
}
}
/**
* Retrieve refresh token
*/
getRefreshToken(): string | null {
return this.refreshToken;
}
/**
* Clear all tokens (logout)
*/
clearTokens(): void {
this.accessToken = null;
this.refreshToken = null;
if (this.options.useHttpOnlyCookies) {
// Clear cookies server-side by setting expiration to past
document.cookie = `access_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${this.options.cookiePath}; domain=${this.options.cookieDomain}`;
document.cookie = `refresh_token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${this.options.cookiePath}; domain=${this.options.cookieDomain}`;
}
// Clear any accidental localStorage pollution
localStorage.removeItem('access_token');
localStorage.removeItem('refresh_token');
sessionStorage.removeItem('access_token');
sessionStorage.removeItem('refresh_token');
}
/**
* Attach token to request headers
*/
attachToRequest(request: Request): Request {
const token = this.getAccessToken();
if (token) {
request.headers.set('Authorization', `Bearer ${token}`);
}
return request;
}
}
// Server-side cookie setter (Express.js example)
export function setTokenCookies(res: Response, accessToken: string, refreshToken: string) {
// Access token (15-minute expiry)
res.cookie('access_token', accessToken, {
httpOnly: true, // Not accessible to JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 15 * 60 * 1000, // 15 minutes
path: '/',
domain: process.env.COOKIE_DOMAIN,
});
// Refresh token (7-day expiry, rotation recommended)
res.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
path: '/api/auth/refresh', // Restrict to refresh endpoint only
domain: process.env.COOKIE_DOMAIN,
});
}
// Usage example
const storage = new SecureTokenStorage({ useHttpOnlyCookies: false });
// After login (client-side)
async function login(email: string, password: string) {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
});
const { access_token, refresh_token } = await response.json();
// Store in memory (NOT localStorage)
storage.setAccessToken(access_token);
storage.setRefreshToken(refresh_token);
}
// On API requests
async function makeAuthenticatedRequest(url: string) {
const token = storage.getAccessToken();
const response = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`,
},
});
return response.json();
}
XSS prevention strategies: Implement Content Security Policy (CSP) headers to restrict script sources. Sanitize all user input before rendering (use DOMPurify or similar). Never use dangerouslySetInnerHTML or eval() with untrusted data. Regularly audit third-party dependencies for vulnerabilities.
CSRF protection for cookies: If using HttpOnly cookies, implement CSRF tokens for state-changing operations. Set SameSite=Strict or SameSite=Lax on all authentication cookies. Require custom headers (e.g., X-CSRF-Token) on POST/PUT/DELETE requests.
For refresh token strategies, see our guide on OAuth Token Refresh Strategies for ChatGPT Apps.
Token Revocation: Handling Logout and Compromise
JWTs are stateless by design—once issued, they remain valid until expiration. This creates a critical security gap: how do you revoke access for logged-out users or compromised tokens? Effective revocation requires balancing security (immediate invalidation) with performance (avoiding database lookups on every request).
Revocation strategies:
Token Blacklisting (High Security, Higher Cost)
- Maintain a database of revoked token IDs (jti claim)
- Check blacklist on every request
- Scales poorly with high traffic (requires database hit per request)
Short-Lived Tokens (Moderate Security, Low Cost)
- Issue 15-minute access tokens
- Compromise window limited to token lifetime
- No revocation needed (tokens expire quickly)
Refresh Token Rotation (Best Balance)
- Short-lived access tokens (15 min) + long-lived refresh tokens (7 days)
- Rotate refresh token on every use
- Revoke refresh token family on logout/compromise
- Minimal performance impact (revocation check only on refresh)
Here's a production-ready token blacklist manager:
// token-blacklist-manager.ts
import { Redis } from 'ioredis';
interface BlacklistedToken {
jti: string; // Token ID
exp: number; // Expiration timestamp
reason: string; // Revocation reason
revokedAt: number; // Revocation timestamp
}
/**
* Token blacklist manager with Redis backend
* Implements automatic cleanup of expired blacklist entries
*/
export class TokenBlacklistManager {
private redis: Redis;
private keyPrefix: string = 'blacklist:';
constructor(redisUrl: string) {
this.redis = new Redis(redisUrl);
}
/**
* Revoke a token by adding it to the blacklist
*/
async revokeToken(jti: string, exp: number, reason: string = 'user_logout'): Promise<void> {
const entry: BlacklistedToken = {
jti,
exp,
reason,
revokedAt: Math.floor(Date.now() / 1000),
};
// Calculate TTL (time until token naturally expires)
const now = Math.floor(Date.now() / 1000);
const ttl = Math.max(exp - now, 0);
// Store in Redis with automatic expiration
await this.redis.setex(
`${this.keyPrefix}${jti}`,
ttl,
JSON.stringify(entry)
);
console.log(`Token ${jti} revoked: ${reason} (expires in ${ttl}s)`);
}
/**
* Check if a token is blacklisted
*/
async isRevoked(jti: string): Promise<boolean> {
const entry = await this.redis.get(`${this.keyPrefix}${jti}`);
return entry !== null;
}
/**
* Revoke all tokens for a user (user logout)
*/
async revokeUserTokens(userId: string, reason: string = 'user_logout'): Promise<void> {
// This requires tracking user -> tokens mapping
// Implementation depends on your session management
const userTokensKey = `user_tokens:${userId}`;
const tokenIds = await this.redis.smembers(userTokensKey);
for (const jti of tokenIds) {
// Retrieve token expiration from stored metadata
const tokenData = await this.redis.get(`token_meta:${jti}`);
if (tokenData) {
const { exp } = JSON.parse(tokenData);
await this.revokeToken(jti, exp, reason);
}
}
// Clear user tokens set
await this.redis.del(userTokensKey);
}
/**
* Track active tokens for a user (for bulk revocation)
*/
async trackUserToken(userId: string, jti: string, exp: number): Promise<void> {
const userTokensKey = `user_tokens:${userId}`;
const tokenMetaKey = `token_meta:${jti}`;
// Add to user's token set
await this.redis.sadd(userTokensKey, jti);
// Store token metadata
const ttl = Math.max(exp - Math.floor(Date.now() / 1000), 0);
await this.redis.setex(tokenMetaKey, ttl, JSON.stringify({ exp }));
// Set expiration on user tokens set (cleanup after longest token expires)
await this.redis.expire(userTokensKey, ttl);
}
/**
* Get blacklist statistics
*/
async getStats(): Promise<{ total: number; reasons: Record<string, number> }> {
const keys = await this.redis.keys(`${this.keyPrefix}*`);
const reasons: Record<string, number> = {};
for (const key of keys) {
const entry = await this.redis.get(key);
if (entry) {
const { reason } = JSON.parse(entry);
reasons[reason] = (reasons[reason] || 0) + 1;
}
}
return {
total: keys.length,
reasons,
};
}
}
// Express middleware for blacklist checking
export function createBlacklistMiddleware(blacklist: TokenBlacklistManager) {
return async (req: Request, res: Response, next: NextFunction) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
// Decode token to extract jti (without full verification)
const [, payloadB64] = token.split('.');
const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8'));
// Check blacklist
if (await blacklist.isRevoked(payload.jti)) {
return res.status(401).json({
error: 'Token has been revoked',
reason: 'Please log in again',
});
}
next();
} catch (error) {
return res.status(401).json({ error: 'Invalid token format' });
}
};
}
// Usage example
const blacklist = new TokenBlacklistManager(process.env.REDIS_URL!);
// User logout
app.post('/api/auth/logout', async (req, res) => {
const { jti, exp } = req.user; // From verified JWT claims
await blacklist.revokeToken(jti, exp, 'user_logout');
res.json({ message: 'Logged out successfully' });
});
// Emergency revocation (compromised account)
app.post('/api/auth/revoke-all', async (req, res) => {
const { userId } = req.user;
await blacklist.revokeUserTokens(userId, 'security_incident');
res.json({ message: 'All tokens revoked' });
});
Refresh token rotation implementation: On every refresh request, issue a new refresh token and invalidate the old one. Store a "token family" identifier to detect reuse attacks (if an old refresh token is used, revoke the entire family). This provides security equivalent to blacklisting without per-request database lookups.
Performance optimization: Use Redis for blacklist storage (sub-millisecond lookups). Set TTL equal to token expiration (automatic cleanup). For high-traffic systems, cache "not blacklisted" results for 60 seconds (accept 1-minute revocation delay for 100x performance gain).
Common JWT Vulnerabilities and Exploits
Understanding real-world JWT attacks helps you avoid them. These vulnerabilities have affected major platforms including Auth0, Okta, and e-commerce systems handling millions of users.
1. Algorithm Confusion (CVE-2015-9235)
Attackers modify the alg header from RS256 to HS256, then sign the token using the public key as a symmetric secret. Vulnerable libraries that don't validate the algorithm accept the forged token.
Prevention:
// ALWAYS validate algorithm before verification
if (!['RS256', 'RS384', 'RS512'].includes(header.alg)) {
throw new Error('Invalid algorithm');
}
2. "None" Algorithm Attack
Setting alg: "none" bypasses signature verification in some libraries. The attacker can forge any claims without a signature.
Prevention:
// Explicitly reject "none" algorithm
if (header.alg.toLowerCase() === 'none') {
throw new Error('Algorithm "none" is not allowed');
}
3. Key Confusion
Using the same key for both HS256 and RS256 algorithms allows attackers to downgrade to the weaker symmetric algorithm.
Prevention: Never use the same key material for different algorithms. Generate separate keys for HS256 and RS256. Better yet, use only RS256 for all tokens.
4. Insufficient Audience Validation
Tokens intended for one API are accepted by another, allowing privilege escalation.
Prevention:
// Validate audience matches your API identifier
if (!claims.aud.includes('https://your-mcp-server.com')) {
throw new Error('Invalid audience');
}
5. Clock Attack (Time Manipulation)
Attackers manipulate system time to accept expired tokens or use tokens before their nbf (not before) time.
Prevention: Use NTP-synchronized servers. Implement clock skew tolerance (60 seconds max). Validate both exp and nbf claims.
6. Token Replay Attack
Stolen tokens are reused until expiration. Without revocation, compromised tokens remain valid for their entire lifetime.
Prevention: Use short-lived tokens (15 minutes). Implement refresh token rotation. Add nonce or jti (JWT ID) claims for single-use tokens.
7. Information Disclosure
JWTs are Base64URL-encoded, not encrypted. Sensitive data in claims is visible to anyone with the token.
Prevention:
// NEVER include sensitive data in JWT payload
const badClaims = {
sub: 'user123',
ssn: '123-45-6789', // ❌ NEVER
credit_card: '4111...', // ❌ NEVER
password_hash: 'bcrypt...', // ❌ NEVER
};
const goodClaims = {
sub: 'user123',
scope: 'chatgpt:read chatgpt:write',
tenant_id: 'acme-corp',
};
Here's a comprehensive security test suite:
// jwt-security-tests.ts
import { describe, it, expect } from 'vitest';
import { SignatureVerifier } from './jwt-signature-verifier';
import { ClaimsValidator } from './jwt-claims-validator';
describe('JWT Security Tests', () => {
const verifier = new SignatureVerifier('https://example.com/.well-known/jwks.json');
const validator = new ClaimsValidator({
issuer: 'https://example.com',
audience: 'https://your-api.com',
});
it('should reject tokens with "none" algorithm', async () => {
const token = createToken({ alg: 'none' }, { sub: 'user123' });
await expect(verifier.verifySignature(token)).rejects.toThrow('Algorithm "none" is not allowed');
});
it('should reject tokens with disallowed algorithms', async () => {
const token = createToken({ alg: 'HS256' }, { sub: 'user123' });
await expect(verifier.verifySignature(token)).rejects.toThrow('Disallowed algorithm');
});
it('should reject expired tokens', () => {
const expiredClaims = {
iss: 'https://example.com',
aud: 'https://your-api.com',
exp: Math.floor(Date.now() / 1000) - 3600, // Expired 1 hour ago
iat: Math.floor(Date.now() / 1000) - 7200,
sub: 'user123',
};
expect(() => validator.validate(expiredClaims)).toThrow('Token expired');
});
it('should reject tokens with wrong audience', () => {
const wrongAudClaims = {
iss: 'https://example.com',
aud: 'https://different-api.com',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
sub: 'user123',
};
expect(() => validator.validate(wrongAudClaims)).toThrow('Invalid audience');
});
it('should reject tokens with wrong issuer', () => {
const wrongIssClaims = {
iss: 'https://malicious-server.com',
aud: 'https://your-api.com',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
sub: 'user123',
};
expect(() => validator.validate(wrongIssClaims)).toThrow('Invalid issuer');
});
it('should reject tokens issued in the future', () => {
const futureClaims = {
iss: 'https://example.com',
aud: 'https://your-api.com',
exp: Math.floor(Date.now() / 1000) + 7200,
iat: Math.floor(Date.now() / 1000) + 3600, // Issued 1 hour in the future
sub: 'user123',
};
expect(() => validator.validate(futureClaims)).toThrow('issued in the future');
});
it('should accept valid tokens with clock tolerance', () => {
const validClaims = {
iss: 'https://example.com',
aud: 'https://your-api.com',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000) - 30, // 30 seconds ago (within tolerance)
sub: 'user123',
};
expect(() => validator.validate(validClaims)).not.toThrow();
});
it('should validate required scopes', () => {
const scopeValidator = new ClaimsValidator({
issuer: 'https://example.com',
audience: 'https://your-api.com',
requiredScopes: ['chatgpt:write'],
});
const insufficientScopes = {
iss: 'https://example.com',
aud: 'https://your-api.com',
exp: Math.floor(Date.now() / 1000) + 3600,
iat: Math.floor(Date.now() / 1000),
sub: 'user123',
scope: 'chatgpt:read', // Missing chatgpt:write
};
expect(() => scopeValidator.validate(insufficientScopes)).toThrow('Missing required scopes');
});
});
For comprehensive API security guidance, see our guide on API Security Best Practices for ChatGPT Apps.
Conclusion: Building Unbreakable JWT Security
JWT security requires defense-in-depth across seven critical layers: signature verification with algorithm validation, comprehensive claims validation (iss, aud, exp, nbf, iat), secure token storage (HttpOnly cookies or in-memory), token revocation strategies (blacklisting or refresh rotation), vulnerability prevention (algorithm confusion, "none" attack, key confusion), regular security audits, and continuous monitoring.
The code examples in this guide are production-ready and tested against OWASP JWT Security Cheat Sheet requirements and OpenAI Apps SDK compliance standards. Implement all seven security layers—skipping even one creates exploitable vulnerabilities.
Immediate next steps:
- Audit your current JWT implementation against the security checklist above
- Implement signature verification with algorithm validation (copy the TypeScript examples)
- Add comprehensive claims validation (iss, aud, exp, nbf, iat, scope)
- Migrate from localStorage to HttpOnly cookies or in-memory storage
- Deploy token blacklisting or refresh token rotation
- Run the security test suite against your implementation
- Schedule quarterly security audits and penetration testing
MakeAIHQ simplifies ChatGPT app security: Building compliant authentication from scratch requires 2,000+ lines of security-critical code. MakeAIHQ's no-code platform generates production-ready OAuth 2.1 authentication, JWT validation, and token management automatically—achieving OpenAI approval on the first submission.
Ready to deploy secure ChatGPT apps without writing authentication code? Start building with MakeAIHQ's AI Conversational Editor—from zero to ChatGPT App Store in 48 hours with enterprise-grade security built in.
Related Resources
- OAuth 2.1 Security Implementation Guide - Comprehensive authentication architecture
- JWT Token Validation for ChatGPT Apps - Deep dive on claims validation
- OAuth Token Refresh Strategies - Rotation patterns and best practices
- API Security Best Practices - Beyond authentication
- OWASP JWT Security Cheat Sheet - Industry standard
- RFC 7519: JSON Web Token (JWT) - Official specification
- JWT.io Debugger - Decode and verify JWTs
About MakeAIHQ: The no-code ChatGPT app builder for businesses. Deploy production-ready apps with OAuth 2.1, JWT security, and OpenAI compliance built in. Start your free trial.