CORS Deep Dive: Configuring Cross-Origin Resource Sharing
Understand how CORS actually works at the protocol level and implement secure, correct configurations that don't resort to wildcard allow-all patterns.

CORS errors are the bane of frontend development. The instinct when you see "Access-Control-Allow-Origin" in red is to throw Access-Control-Allow-Origin: * on the server and move on. This works in development and creates security holes in production.
Understanding CORS at the protocol level—what the browser sends, what the server should respond, and why preflight exists—turns a frustrating debugging experience into straightforward configuration.
What Happens Before Your Code Runs
CORS is enforced by the browser, not the server. The server merely declares its policy through response headers. The browser decides whether to block the response based on those headers.
// ❌ Common misconception: CORS blocks the request
// Reality: The request IS sent. The browser blocks the RESPONSE.
// Your server processes the request either way!
// This means CORS alone doesn't prevent server-side effects.
// A POST that creates a database record still executes.
// The browser just hides the response from JavaScript.// The browser's CORS decision flow:
interface CORSCheck {
step1: "Is request origin different from resource origin?";
step2: "If same-origin → allow, no CORS headers needed";
step3: "If cross-origin → check Access-Control-Allow-Origin header";
step4: "If header matches origin → allow JavaScript to read response";
step5: "If header missing or mismatched → block response from JS";
}
// For non-simple requests, add a preflight step BEFORE step 1:
interface PreflightCheck {
trigger: "Custom headers, non-GET/POST methods, or non-simple content types";
action: "Send OPTIONS request with Access-Control-Request-* headers";
serverResponds: "With Access-Control-Allow-* headers declaring policy";
browserDecides: "Whether to send the actual request based on the policy";
}The key insight is that CORS is a browser-to-server negotiation protocol. Server-to-server requests, curl, and Postman don't involve CORS at all because there's no browser enforcing the policy.
Preflight Requests Demystified
"Simple" requests (GET, POST with form content types, limited headers) skip preflight. Everything else triggers an OPTIONS request that must succeed before the actual request is sent.
// ❌ Not understanding why a preflight is triggered
// "My GET request shouldn't need a preflight!"
// It does if you added custom headers:
fetch("https://api.example.com/data", {
headers: {
Authorization: "Bearer token123", // Custom header → preflight
"X-Request-Id": "abc", // Custom header → preflight
},
});// Express middleware: properly handling preflight
import express, { Request, Response, NextFunction } from "express";
const ALLOWED_ORIGINS = new Set([
"https://app.example.com",
"https://staging.example.com",
]);
function corsMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const origin = req.headers.origin;
// Only set CORS headers for allowed origins
if (origin && ALLOWED_ORIGINS.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin"); // Critical for caching
// Handle preflight
if (req.method === "OPTIONS") {
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH"
);
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Request-Id"
);
res.setHeader(
"Access-Control-Max-Age",
"86400" // Cache preflight for 24 hours
);
res.status(204).end();
return;
}
}
next();
}
const app = express();
app.use(corsMiddleware);The Vary: Origin header is crucial and frequently forgotten. Without it, a CDN might cache a response with one origin's CORS headers and serve it to a different origin, resulting in CORS errors.
Dynamic Origin Validation
Production APIs often need to allow multiple origins, including subdomains and preview deployment URLs. Regex-based validation handles this without a wildcard.
// ❌ Reflecting any origin back (equivalent to wildcard)
function unsafeCors(req: Request, res: Response, next: NextFunction) {
res.setHeader(
"Access-Control-Allow-Origin",
req.headers.origin ?? "*" // Reflects attacker's origin!
);
next();
}// ✅ Validating origins against a pattern
function isAllowedOrigin(origin: string): boolean {
const allowedPatterns = [
/^https:\/\/app\.example\.com$/,
/^https:\/\/[a-z0-9-]+\.preview\.example\.com$/,
/^https:\/\/staging\.example\.com$/,
];
// In development, also allow localhost
if (process.env.NODE_ENV === "development") {
allowedPatterns.push(/^http:\/\/localhost:\d+$/);
}
return allowedPatterns.some(pattern => pattern.test(origin));
}
function secureCorsMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const origin = req.headers.origin;
if (origin && isAllowedOrigin(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
if (req.method === "OPTIONS") {
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE"
);
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
);
res.setHeader("Access-Control-Max-Age", "86400");
res.status(204).end();
return;
}
}
next();
}The regex patterns are strict: they match exact domain structures, not substring matches. A loose pattern like /example\.com/ would match evil-example.com—always anchor your patterns with ^ and $.
Credentials and Cookies Across Origins
When your cross-origin request needs to send cookies or use HTTP authentication, CORS becomes more restrictive. The wildcard * is explicitly forbidden with credentials.
// ❌ This doesn't work: wildcard + credentials
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Credentials", "true");
// Browser error: Cannot use wildcard with credentials
// ❌ Reflecting origin without validation + credentials
res.setHeader("Access-Control-Allow-Origin", req.headers.origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
// Security hole: any site can make authenticated requests// ✅ Explicit origin with credentials
function credentialedCorsMiddleware(
req: Request,
res: Response,
next: NextFunction
): void {
const origin = req.headers.origin;
if (origin && isAllowedOrigin(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
// Expose specific response headers to JavaScript
res.setHeader(
"Access-Control-Expose-Headers",
"X-Total-Count, X-Request-Id"
);
if (req.method === "OPTIONS") {
res.setHeader(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE"
);
res.setHeader(
"Access-Control-Allow-Headers",
"Content-Type, Authorization"
);
res.setHeader("Access-Control-Max-Age", "3600");
res.status(204).end();
return;
}
}
next();
}// Client-side: must explicitly opt into credentials
const response = await fetch("https://api.example.com/user", {
credentials: "include", // Send cookies cross-origin
headers: {
"Content-Type": "application/json",
},
});The Access-Control-Expose-Headers header is often overlooked. By default, JavaScript can only read six "CORS-safelisted" response headers. Custom headers like X-Total-Count for pagination are invisible unless explicitly exposed.
CORS Configuration for Common Frameworks
Each framework handles CORS differently. Here are secure configurations for the most popular ones.
// Next.js API route
import type { NextApiRequest, NextApiResponse } from "next";
const allowedOrigins = ["https://app.example.com"];
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const origin = req.headers.origin;
if (origin && allowedOrigins.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Vary", "Origin");
}
if (req.method === "OPTIONS") {
res.setHeader("Access-Control-Allow-Methods", "GET, POST");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
res.setHeader("Access-Control-Max-Age", "86400");
return res.status(204).end();
}
res.json({ data: "response" });
}# FastAPI with strict CORS
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://app.example.com",
"https://staging.example.com",
],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
expose_headers=["X-Total-Count"],
max_age=86400,
)// Go with chi router
package main
import (
"net/http"
"github.com/go-chi/cors"
)
func main() {
r := chi.NewRouter()
r.Use(cors.Handler(cors.Options{
AllowedOrigins: []string{"https://app.example.com"},
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE"},
AllowedHeaders: []string{"Content-Type", "Authorization"},
ExposedHeaders: []string{"X-Total-Count"},
AllowCredentials: true,
MaxAge: 86400,
}))
}Debugging CORS Issues Systematically
When a CORS error appears, follow a systematic approach instead of randomly changing headers.
interface CORSDebugChecklist {
step: string;
check: string;
fix: string;
}
const debugChecklist: CORSDebugChecklist[] = [
{
step: "1. Check the actual error message",
check: "Browser console shows which header is missing or wrong",
fix: "Add the specific header mentioned in the error",
},
{
step: "2. Inspect the preflight",
check: "Network tab → filter by OPTIONS → check response headers",
fix: "Ensure OPTIONS returns 204 with correct CORS headers",
},
{
step: "3. Verify Vary header",
check: "Response includes 'Vary: Origin'",
fix: "Add Vary header to prevent CDN caching issues",
},
{
step: "4. Check credentials mode",
check: "If using credentials, origin cannot be wildcard",
fix: "Set explicit origin and credentials: true",
},
{
step: "5. Check exposed headers",
check: "Custom response headers readable in JS?",
fix: "Add Access-Control-Expose-Headers for custom headers",
},
{
step: "6. Check Max-Age",
check: "Browser might cache a failed preflight",
fix: "Clear browser cache or use incognito to test",
},
];Key Takeaways
CORS is a security mechanism, not an obstacle to work around. The wildcard * is appropriate only for truly public APIs that serve static data without authentication. Everything else deserves explicit origin validation, proper preflight handling, and the Vary: Origin header to prevent caching disasters.
The most common CORS bugs come from three sources: reflecting the origin without validation (makes credentials worthless), forgetting Vary: Origin (causes CDN-related intermittent failures), and not handling the OPTIONS preflight (returns 404 or 405 instead of 204). Fix these three patterns and you'll eliminate 90% of CORS debugging sessions.


