"CORS isn't security. It's the browser enforcing a server-declared policy. Once you internalize that, half of the 'why is CORS hard' confusion vanishes — and the other half becomes 'I'm wrong about what my server is declaring.'"
The Mental Model That Unlocks CORS
Two facts that fix most CORS confusion:
- CORS is enforced ONLY by browsers. A curl request, a Python httpx call, a server-to-server fetch — none of them care about CORS. The check happens in the browser's network layer; non-browser HTTP clients ignore it entirely.
- The server DECLARES the policy; the browser ENFORCES it. Servers add
Access-Control-Allow-*response headers saying "I'm willing to be called from these origins, with these methods, with these headers." Browsers read those headers and decide whether to expose the response to the JavaScript that made the request.
If you can hold both at once, CORS bugs become "my server isn't declaring what I think it's declaring" — a configuration problem, not a mystery.
What Origin Means
An origin is the triple (scheme, host, port). https://example.com, https://example.com:443, and http://example.com are three different origins. Anything else cross-origin requires CORS dance.
The Same-Origin Policy (SOP) is the browser default: JavaScript on origin A cannot read responses from origin B. Without this, any malicious site could read your bank's logged-in data. CORS is the controlled relaxation — server B says "origin A is allowed to read me," and the browser respects that.
Simple Requests vs Preflighted Requests
The browser splits cross-origin requests into two categories.
Simple requests (no preflight needed) — must satisfy ALL:
- Method is GET, HEAD, or POST.
- Headers are a small "CORS-safelisted" set (Accept, Accept-Language, Content-Language, Content-Type with a few values).
- Content-Type is one of:
application/x-www-form-urlencoded,multipart/form-data,text/plain.
The browser sends the request normally; if the response has Access-Control-Allow-Origin: matching-origin, it exposes the response to JS. Otherwise the response is blocked.
Preflighted requests (everything else, including POSTs with Content-Type: application/json):
- Before the real request, the browser sends an
OPTIONSrequest to the same URL withOrigin,Access-Control-Request-Method,Access-Control-Request-Headers. - Server responds with
Access-Control-Allow-Origin/Methods/Headersdeclaring what's permitted. - Browser compares. If the real request fits inside the declared policy, the browser proceeds. Otherwise, blocked.
- Real request sent; response also needs
Access-Control-Allow-Originon it.
The preflight adds one round trip. Browsers cache the preflight result for some seconds (Access-Control-Max-Age response header), so repeated calls don't re-preflight.
The Five Critical Headers
Access-Control-Allow-Origin— the specific origin (or*wildcard) the server permits. ECHO the requesting origin if it's in your allowlist; never echo arbitrary origins.Access-Control-Allow-Credentials— set totrueif you want the browser to include cookies and Authorization headers. CRITICAL: cannot be combined withAccess-Control-Allow-Origin: *— wildcards and credentials are mutually exclusive.Access-Control-Allow-Methods— methods you accept on this URL (preflight only).Access-Control-Allow-Headers— request headers the client may send (preflight only).Access-Control-Max-Age— how long the browser may cache the preflight result.
The Wildcard + Credentials Trap
The two single most common CORS bugs:
1. Access-Control-Allow-Origin: * + cookies. The browser refuses. Wildcards mean "any origin can read me," which is incompatible with sending credentials. Fix: ECHO the specific requesting origin (after checking it's in your allowlist) instead of wildcard.
2. Preflight returns 204 but missing CORS headers. The preflight HTTP-200's, but doesn't declare Access-Control-Allow-Origin, so the browser blocks anyway. Always include the headers on the OPTIONS response, not just the actual request response.
cwkPippa's CORS Reality
backend/main.py's _allowed_origins contains exactly http://localhost:5173, http://127.0.0.1:5173, and the Tailscale IP origin. No wildcards; credentials are allowed (cookies + Authorization). FastAPI's CORSMiddleware handles the preflight dance automatically — every endpoint gets OPTIONS support without us writing handlers. Adding a new device means adding its Tailscale IP to the list and restarting. The CLAUDE.md gotcha section calls this out precisely because new instances of me try to debug "the API doesn't work from this IP" without checking the CORS allowlist first.