s a critical security constraint: browsers explicitly reject wildcard origins when credentials are involved, forcing developers to implement explicit origin validation rather than relying on permissive shortcuts.
Core Solution
Building a robust CORS implementation requires moving beyond generic middleware packages and understanding the underlying negotiation protocol. The following TypeScript implementation demonstrates a production-grade CORS handler that validates origins, manages preflight caching, and enforces credential safety.
Architecture Decisions & Rationale
- Explicit Origin Validation Over Wildcards: Using
* disables credential sharing and exposes APIs to unintended consumers. We implement a dynamic origin validator that checks against an allowlist, supporting both exact matches and controlled regex patterns for staging environments.
- Preflight Caching via
Max-Age: Browsers repeat OPTIONS requests for every non-simple call unless instructed otherwise. Setting Access-Control-Max-Age reduces redundant network chatter and improves perceived performance.
Vary: Origin Header Injection: CDNs and reverse proxies cache responses based on request headers. Without Vary: Origin, a proxy might serve a CORS-permissive response to a blocked origin, creating security leaks. This header ensures cache partitioning by origin.
- Method & Header Whitelisting: Instead of allowing all methods or headers, we explicitly declare what the API supports. This follows the principle of least privilege and prevents accidental exposure of dangerous operations.
Implementation (TypeScript)
import type { Request, Response, NextFunction } from 'express';
interface CorsConfig {
allowedOrigins: string[];
allowedMethods: string[];
allowedHeaders: string[];
supportCredentials: boolean;
preflightMaxAge: number;
}
const DEFAULT_CONFIG: CorsConfig = {
allowedOrigins: [],
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization'],
supportCredentials: false,
preflightMaxAge: 86400, // 24 hours in seconds
};
export function createCorsMiddleware(config: Partial<CorsConfig> = {}) {
const options = { ...DEFAULT_CONFIG, ...config };
return (req: Request, res: Response, next: NextFunction) => {
const requestOrigin = req.headers.origin;
// 1. Validate Origin
const isOriginAllowed = options.allowedOrigins.includes(requestOrigin || '');
if (!isOriginAllowed) {
return next(); // Let downstream middleware or 403 handler decide
}
// 2. Set Core CORS Headers
res.setHeader('Access-Control-Allow-Origin', requestOrigin);
res.setHeader('Access-Control-Allow-Methods', options.allowedMethods.join(', '));
res.setHeader('Access-Control-Allow-Headers', options.allowedHeaders.join(', '));
res.setHeader('Vary', 'Origin');
// 3. Handle Credentials
if (options.supportCredentials) {
res.setHeader('Access-Control-Allow-Credentials', 'true');
}
// 4. Handle Preflight
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Max-Age', String(options.preflightMaxAge));
res.status(204).end();
return;
}
next();
};
}
Why This Structure Works
- Separation of Concerns: The middleware isolates CORS logic from route handlers, keeping business code clean.
- Early Preflight Termination: Responding with
204 No Content on OPTIONS prevents unnecessary database queries or authentication middleware execution during preflight checks.
- Type Safety: The
CorsConfig interface enforces configuration contracts at compile time, reducing runtime misconfigurations.
- Dynamic Origin Handling: By reflecting the exact
Origin header back (after validation), we maintain compatibility with credential-based flows while avoiding wildcard restrictions.
Pitfall Guide
1. Wildcard Origin with Credentials Enabled
Explanation: Browsers explicitly reject Access-Control-Allow-Origin: * when Access-Control-Allow-Credentials: true is present. This combination violates the CORS specification because credentials imply identity, and wildcards imply anonymity.
Fix: Replace * with the exact origin string or implement dynamic origin validation against a trusted allowlist.
Explanation: Caching layers (CDNs, NGINX, Cloudflare) use the Vary header to determine cache keys. Without it, a response intended for app.example.com might be cached and served to malicious.example.com, bypassing CORS restrictions.
Fix: Always include res.setHeader('Vary', 'Origin') in CORS responses to ensure cache partitioning.
3. Preflight Caching Neglect
Explanation: Non-simple requests trigger OPTIONS probes on every call. Without Access-Control-Max-Age, browsers repeat these probes, doubling latency for authenticated or state-changing operations.
Fix: Set Access-Control-Max-Age to a reasonable duration (e.g., 86400 seconds) and invalidate it when API contracts change.
Explanation: The Access-Control-Allow-Headers directive is case-sensitive in many browser implementations. Sending X-Custom-Token when the server only allows x-custom-token will trigger a block.
Fix: Standardize header casing across frontend and backend, or explicitly list all expected variations in the allowlist.
5. Backend-Only Validation Testing
Explanation: Testing APIs with curl, Postman, or Node.js fetch bypasses the browser sandbox entirely. These clients ignore CORS headers, creating false confidence that the API is correctly configured.
Fix: Always validate cross-origin behavior using browser DevTools Network tab or automated browser testing tools (Playwright, Cypress).
6. Over-Permissive Method Allowlists
Explanation: Returning Access-Control-Allow-Methods: * or listing dangerous verbs (DELETE, PATCH) without corresponding route protection exposes the API to unintended mutations.
Fix: Whitelist only the HTTP methods explicitly implemented in your router. Align CORS configuration with route-level authorization.
7. Frontend Credential Flag Desynchronization
Explanation: The browser will not attach cookies or HTTP auth headers unless the fetch/Axios configuration explicitly sets withCredentials: true (or credentials: 'include'). If the server allows credentials but the client omits the flag, authentication silently fails.
Fix: Synchronize client-side credential flags with server-side Allow-Credentials headers. Document this requirement in API contracts.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Single frontend, single backend | Static origin string in config | Simple, predictable, zero runtime validation overhead | Minimal |
| Multi-tenant SaaS or staging environments | Dynamic origin validation against allowlist | Supports multiple verified domains without wildcard security risks | Low (CPU for string matching) |
| High-frequency state-changing API | Preflight caching (Max-Age: 86400) + Vary: Origin | Eliminates redundant OPTIONS requests, reduces CDN egress | Low (cache memory) |
| Public read-only API | Wildcard origin (*), no credentials | Maximizes accessibility, simplifies client configuration | Zero |
| Authenticated microservice mesh | Explicit origin + Allow-Credentials: true + mTLS | Enforces strict identity verification, prevents credential leakage | Medium (certificate management) |
Configuration Template
// cors.config.ts
import { createCorsMiddleware } from './cors.middleware';
const isProduction = process.env.NODE_ENV === 'production';
export const corsPolicy = createCorsMiddleware({
allowedOrigins: isProduction
? [
'https://app.yourdomain.com',
'https://admin.yourdomain.com',
'https://partner-portal.yourdomain.com'
]
: ['http://localhost:3000', 'http://localhost:5173'],
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Request-ID'],
supportCredentials: true,
preflightMaxAge: 86400,
});
Quick Start Guide
- Install dependencies:
npm install express @types/express
- Create the middleware file: Copy the
createCorsMiddleware implementation into src/middleware/cors.ts
- Register in your app:
import express from 'express';
import { corsPolicy } from './middleware/cors';
const app = express();
app.use(corsPolicy);
app.use(express.json());
// Define routes...
- Configure frontend client:
// Example with native fetch
fetch('https://api.yourdomain.com/data', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ payload: true })
});
- Validate: Open browser DevTools β Network tab β trigger a request. Verify
Access-Control-Allow-Origin matches your frontend URL and OPTIONS requests return 204 with Max-Age headers.