CORS and cross-origin headers
Restrict Access-Control-Allow-Origin to an explicit origin list; with credentials, disallow *; answer OPTIONS preflights with correct method and header allowlists.
Do not reflect request Origin blindly; use a configured allowlist (including subdomain policy). Minimize Allow-Methods and Allow-Headers; Expose-Headers should list only headers the browser client truly needs to read.
Non-browser clients ignore CORS—servers still need authentication. In DevTools, distinguish simple requests from preflight failures.
Complete CORS configuration (Express / Nginx / CDN)
Express.js — dynamic origin allowlist (database-driven)
// cors-middleware.ts
import cors from 'cors';
import { db } from './db';
// Origin allowlist (can also load dynamically from database or config center)
const STATIC_ALLOWED_ORIGINS = new Set([
'https://app.example.com',
'https://admin.example.com',
]);
// Dynamic allowlist: validate partner domains from database
async function isOriginAllowed(origin: string): Promise<boolean> {
if (STATIC_ALLOWED_ORIGINS.has(origin)) return true;
// Database-driven domain validation (partner integrations)
const allowed = await db.allowedOrigin.findUnique({
where: { origin, active: true }
});
return !!allowed;
}
export const corsMiddleware = cors({
origin: async (origin, callback) => {
// Non-browser requests (no Origin header) — server handles auth separately
if (!origin) return callback(null, false);
const allowed = await isOriginAllowed(origin);
if (allowed) {
callback(null, origin); // return specific origin, not *
} else {
callback(new Error(`Origin ${origin} not allowed`));
}
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
exposedHeaders: ['X-Request-Id'], // only expose headers the frontend truly needs
credentials: true, // use when credentials are required (disables * origin)
maxAge: 86400, // OPTIONS preflight cache 24 hours
});
OPTIONS preflight request handling logic
// Handle OPTIONS preflight (short-circuit, skip business logic)
app.options('*', corsMiddleware, (req, res) => {
// Preflight response: 204 No Content
res.status(204).end();
});
// Or manually implement preflight response
app.use((req, res, next) => {
const origin = req.headers.origin;
if (req.method === 'OPTIONS') {
if (origin && STATIC_ALLOWED_ORIGINS.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,DELETE,PATCH');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization');
res.setHeader('Access-Control-Max-Age', '86400');
res.setHeader('Vary', 'Origin'); // prevent cache poisoning
return res.status(204).end();
}
return res.status(403).end();
}
next();
});
Nginx CORS configuration
# nginx.conf — CORS configuration (declare in only one place, avoid duplicating with origin)
server {
location /api/ {
# Allowlist origin validation
set $cors_origin "";
if ($http_origin ~* "^https://(app|admin)\.example\.com$") {
set $cors_origin $http_origin;
}
# Preflight request handling
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, DELETE, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization';
add_header 'Access-Control-Max-Age' 86400;
add_header 'Vary' 'Origin';
return 204;
}
add_header 'Access-Control-Allow-Origin' $cors_origin always;
add_header 'Vary' 'Origin' always;
proxy_pass http://backend;
}
}
CORS hardening flow (skill-flow-block)
[ Browser cross-origin: Origin + method + custom headers / Content-Type ]
│
▼
┌─────────────│ Decide: simple request or OPTIONS preflight required
│Preflight │──── Gateway/framework: short-circuit OPTIONS →204 + ACAO/ACM/ACH/ACMA
└─────────────│
│
▼
┌─────────────│ Allowlist match; no reflected *; credentials →single origin + ACC
│Allow-Origin│──── Minimal Expose-Headers; Vary: Origin to avoid cache poisoning
└─────────────│
│
▼
┌─────────────│ CDN / API gateway / origin: declare CORS in one place—no duplicates
│Layer sync │
└─────────────┘
When agents emit middleware, default to explicit origin lists + preflight Max-Age placeholders; reflected Origin or * with credentials are defects to fix.
Preflight (OPTIONS) vs simple requests
Non-simple methods (e.g. PUT, PATCH, DELETE) or non-simple headers / Content-Type (e.g. application/json) trigger a browser OPTIONS with Access-Control-Request-Method and optional Access-Control-Request-Headers. The preflight response must allow the eventual request or the main call never fires.
- Successful preflight often returns 204; include matching
Access-Control-Allow-Origin(andAccess-Control-Allow-Credentials: truewhen using credentials). Access-Control-Max-Agereduces OPTIONS traffic; very long values slow policy rollouts—trade off deliberately.
Allow-Origin / Methods / Headers
Access-Control-Allow-Methods and Access-Control-Allow-Headers should cover the preflight ask but not exceed product needs. Access-Control-Expose-Headers defaults to a small safelist; list custom response headers explicitly if JS must read them.
- Wildcard
*is incompatible with credentialed requests (cookies /Authorization). - When adding APIs, update CORS config in the same change—review as part of API review.
Credentials request secure configuration
// Frontend: cross-origin request with credentials
fetch('https://api.example.com/user', {
method: 'GET',
credentials: 'include', // include cookies
headers: { 'Authorization': 'Bearer ' + token },
});
// Server response headers (credentials requests must satisfy all of the following)
// ✅ Access-Control-Allow-Origin must be a specific origin (not *)
res.setHeader('Access-Control-Allow-Origin', 'https://app.example.com');
// ✅ Must explicitly declare credentials allowed
res.setHeader('Access-Control-Allow-Credentials', 'true');
// ✅ Prevent cache poisoning
res.setHeader('Vary', 'Origin');
// ❌ The following combination is invalid (browser will reject)
// Access-Control-Allow-Origin: *
// Access-Control-Allow-Credentials: true ← incompatible with *
// CDN layer (Cloudflare Workers): avoid duplicating CORS headers from origin
addEventListener('fetch', event => {
event.respondWith(handleRequest(event.request));
});
async function handleRequest(request) {
const origin = request.headers.get('Origin');
const ALLOWED = ['https://app.example.com'];
const response = await fetch(request);
const newHeaders = new Headers(response.headers);
if (origin && ALLOWED.includes(origin)) {
newHeaders.set('Access-Control-Allow-Origin', origin);
newHeaders.set('Access-Control-Allow-Credentials', 'true');
newHeaders.set('Vary', 'Origin');
}
return new Response(response.body, { headers: newHeaders });
}SKILL template
---
name: cors-headers-hardening
description: Configure or review CORS: Origin allowlist, preflight handling, credentials mode, dynamic domain validation
---
# Steps
1. Review Allow-Origin: forbid reflected Origin; allowlist with Set or database-driven validation
2. credentials: true: Allow-Origin must be a specific origin, cannot use *
3. Add Vary: Origin: prevent shared caches from serving A's CORS headers to B
4. OPTIONS preflight short-circuit: return 204, skip business logic, set Max-Age to reduce preflight frequency
5. Allow-Methods: minimal set (only methods the API actually uses)
6. Allow-Headers: minimal set (Content-Type, Authorization, X-CSRF-Token)
7. Expose-Headers: list only headers the frontend truly needs to read
8. Nginx: declare CORS in one place only, avoid conflicts between CDN and origin
9. Cloudflare Workers: centrally manage CORS at edge; delete duplicate headers from origin
10. Dynamic allowlist: database-driven partner domain validation with active flag
11. Non-browser clients: ignore CORS but still require server-side authentication
12. Test: verify preflight 204, credentialed request, and non-allowlisted origin 403 each
# Anti-patterns
- Do NOT reflect request Origin directly (unless validating against an allowlist first)
- Do NOT combine Access-Control-Allow-Origin: * with credentials: true
- Do NOT declare CORS headers in multiple layers (CDN + gateway + origin)