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.
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.
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.
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
Prefer application-level CORS when you can—Nginx if is brittle—but this pattern unblocks teams stuck behind a legacy edge.
FastAPI sketch
Django (django-cors-headers)
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.