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 (and Access-Control-Allow-Credentials: true when using credentials).
  • Access-Control-Max-Age reduces OPTIONS traffic; very long values slow policy rollouts—trade off deliberately.
Preflight heuristic (this page)

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)

Back to skills More skills