CORS — How It Works and How to Fix It

Cross-Origin Resource Sharing is not a server bug you toggle off—it’s the browser enforcing a contract between your frontend origin and your API origin. When the contract is wrong, the network tab shows a failed request even though the origin returned HTTP 200.

Why browsers enforce it

Documents at https://app.example cannot read responses from https://api.example unless the API explicitly opts in with Access-Control-Allow-Origin (and related headers). That keeps third-party sites from scraping authenticated JSON with your users’ cookies. The pain shows up when your SPA and API are cross-origin by design.

What preflight is doing

Non-simple requests trigger an OPTIONS call first. The browser sends Access-Control-Request-Method and maybe Access-Control-Request-Headers. Your server answers with allowed methods, headers, and optionally Access-Control-Max-Age so repeat visits skip the round trip. If OPTIONS 404s or omits Allow-Headers, the real POST never fires.

Access-Control-Allow-Origin: https://app.example Access-Control-Allow-Methods: GET, POST, OPTIONS Access-Control-Allow-Headers: Authorization, Content-Type Access-Control-Allow-Credentials: true

Five misconfigs and how to fix them

1. Wildcard with credentials

Access-Control-Allow-Origin: * plus credentials: 'include' is invalid—Chrome rejects it outright. Mirror the request’s Origin header when it matches an allowlist instead.

const cors = require('cors'); app.use(cors({ origin: ['https://app.example', 'https://staging.example'], credentials: true }));

2. 200 without CORS headers

The handler runs but forgets headers on success and error paths—especially 401 JSON bodies. Centralize middleware so every response path gets the same decorator.

3. OPTIONS not routed

API gateways or Flask blueprints that only register @app.route(..., methods=['GET','POST']) drop OPTIONS. Add explicit OPTIONS handlers or middleware that short-circuits before auth.

4. Missing Vary: Origin behind a CDN

If you vary Allow-Origin by caller, cache keys must include Origin or you serve user A’s headers to user B.

add_header Vary "Origin" always;

5. Preflight cache poisoning via wrong Max-Age

A permissive preflight cached for hours hides tight fixes. Use 0 while debugging, then settle on minutes—not days—until you trust the matrix.

Nginx proxy in front of Node

location /api/ { if ($request_method = OPTIONS) { add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always; add_header Access-Control-Allow-Headers "Authorization, Content-Type" always; return 204; } proxy_pass http://127.0.0.1:3000; }

Prefer application-level CORS when you can—Nginx if is brittle—but this pattern unblocks teams stuck behind a legacy edge.

FastAPI sketch

from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["https://app.example"], allow_credentials=True, allow_methods=["*"], allow_headers=["Authorization", "Content-Type"], )

Django (django-cors-headers)

CORS_ALLOWED_ORIGINS = [ "https://app.example", ] CORS_ALLOW_CREDENTIALS = True CSRF_TRUSTED_ORIGINS = ["https://app.example"]

Django separates CSRF trusted origins from CORS—you need both when the SPA posts cookies across sites. Missing CSRF_TRUSTED_ORIGINS surfaces as 403s that look like CORS failures in the console because the preflight succeeded.

Debugging checklist

When stuck, capture the failing request in DevTools: confirm whether it’s “simple” or preflight, verify the response includes every allowed header name the browser asked for (case-insensitive match on names, not values), and replay with curl -v -X OPTIONS using the same Origin. If middleware order matters—auth before CORS—swap so CORS answers fail fast for browsers while still logging security events server-side.

Local development hosts

Adding http://localhost:5173 to allow_origins is fine for dev, but strip those entries before production deploys or you create predictable reflection points. Use environment-driven arrays and reject requests when NODE_ENV=production still sees dev origins in configuration.

Open CORSFixer →