CORS — The Deep Dive
CORS (Cross-Origin Resource Sharing) is one of the most misunderstood browser security mechanisms. It doesn’t protect your server — it tells the browser what cross-origin requests are allowed. Misconfiguring it either breaks your API for legitimate clients or accidentally allows cross-site attacks.
What CORS actually does
The same-origin policy (SOP) is a browser security rule: scripts on evil.com can’t read responses from api.example.com. The browser enforces this by default.
CORS is the opt-out: when api.example.com wants to allow specific cross-origin scripts to read its responses, it sends headers telling the browser “yes, this is OK.”
Critical: CORS is enforced by the browser, not the server. A non-browser client (curl, Python script, mobile app) ignores CORS entirely. CORS doesn’t protect your API; it controls browser access.
The request flow
Simple request
A “simple” request (GET/HEAD/POST with Content-Type: text/plain or application/x-www-form-urlencoded and a few standard headers) doesn’t require a preflight:
Browser: GET /api/users HTTP/1.1
Origin: https://app.example.com
Server: HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
(response body...)
If the browser sees Access-Control-Allow-Origin matching its origin, JS gets to read the response. Otherwise: the request was sent (and may have side effects on the server), but JS sees an error.
Preflighted request
For anything beyond simple (PUT, DELETE, custom headers, Content-Type: application/json, etc.), the browser sends an OPTIONS preflight first:
Browser: OPTIONS /api/users/42 HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: content-type, authorization
Server: HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 86400
Browser: PUT /api/users/42 HTTP/1.1
(actual request)
The preflight asks “are you OK with a PUT, with these headers, from this origin?” Only then does the actual request go out. Max-Age caches the preflight result so subsequent requests skip the check.
Pitfalls
Pitfall 1: Allow-Origin: * with credentials
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
This combination is explicitly disallowed by the spec. Browsers reject it. If you set both, your API won’t work from the browser at all.
If you need cookie-based auth (credentials: 'include'), echo back the specific origin:
@app.middleware("http")
async def cors(request, call_next):
response = await call_next(request)
origin = request.headers.get("origin")
if origin in ALLOWED_ORIGINS:
response.headers["Access-Control-Allow-Origin"] = origin
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Vary"] = "Origin"
return response
Vary: Origin matters for caching — without it, a CDN may cache the response with one origin and serve it for another.
Pitfall 2: Origin reflection
Trying to be flexible:
response.headers["Access-Control-Allow-Origin"] = request.headers["origin"] # DANGER
You’re effectively Allow-Origin: * but with credentials, because every origin is echoed. Attacker-controlled site evil.com makes a credentialed request; browser sees Allow-Origin: evil.com echoed back; reads the response.
Always check against an allowlist:
ALLOWED = {"https://app.example.com", "https://admin.example.com"}
origin = request.headers.get("origin")
if origin in ALLOWED:
response.headers["Access-Control-Allow-Origin"] = origin
Pitfall 3: Wildcard subdomain regex with weak match
# Naive: allow *.example.com
if re.match(r".*\.example\.com$", origin):
response.headers["Access-Control-Allow-Origin"] = origin
Attacker registers example.com.evil.com. Matches your regex.
Fix:
if re.match(r"^https://[a-z0-9-]+\.example\.com$", origin):
...
Anchor the regex (^ and $); restrict the character class; require https:// scheme.
Pitfall 4: CORS isn’t CSRF protection
CORS controls who can read the response. The request itself was sent. For state-changing endpoints, an attacker doesn’t need to read the response — they just need the request to fire (e.g., transferring money).
CSRF defenses are separate:
- CSRF tokens — server generates a per-session token; submitted form / API call includes it; server validates.
- SameSite cookies —
SameSite=LaxorSameSite=Stricton session cookies prevents them from being sent with cross-site requests. - Custom request headers — a header like
X-Requested-With: XMLHttpRequestrequires a preflight (preflighted requests check CORS first). But this is brittle — better as defense in depth.
The combination: SameSite=Lax + CSRF token + strict CORS = layered defense.
Pitfall 5: Forgetting the OPTIONS preflight in middleware
If your auth middleware demands a valid token before responding to OPTIONS, browsers can’t preflight and CORS fails. Preflight responses must not require auth.
@app.middleware("http")
async def auth_middleware(request, call_next):
if request.method == "OPTIONS":
return await call_next(request)
# auth checks here
...
FastAPI’s CORSMiddleware handles this; custom middleware sometimes doesn’t.
Pitfall 6: Vary: Origin not set
If your response varies by origin (allowlist check), set Vary: Origin:
Vary: Origin
Access-Control-Allow-Origin: https://app.example.com
Without it, CDNs / caches serve the same Allow-Origin value to clients with different origins → wrong-origin gets cached headers → either too permissive or broken.
FastAPI CORSMiddleware
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com", "https://admin.example.com"],
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
expose_headers=["X-Total-Count"],
max_age=600,
)
Critical defaults:
allow_origins=["*"]+allow_credentials=True→ CORSMiddleware errors out (correct).allow_origin_regexexists for pattern matching — apply the anchoring rules above.
Django
Use django-cors-headers:
INSTALLED_APPS += ["corsheaders"]
MIDDLEWARE.insert(0, "corsheaders.middleware.CorsMiddleware")
CORS_ALLOWED_ORIGINS = ["https://app.example.com"]
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOWED_ORIGIN_REGEXES for pattern matching — same anchoring rules.
What about preflight performance?
Every request triggering a preflight = 2 round trips. For chatty APIs from browsers, this hurts.
Mitigations:
Access-Control-Max-Age: 86400caches the preflight for 24h (max in most browsers; Chrome caps at 7200s = 2h actually).- Use simple requests where possible — GET + minimal headers don’t preflight.
- Bundle requests — fewer total requests = fewer preflights.
For non-browser clients (mobile apps, server-to-server) preflight doesn’t matter; they don’t enforce CORS.
Common interview gotcha: “we enabled CORS but it’s still failing”
The symptoms are confusing because the error appears in the JS console; the server log shows the request succeeded. Diagnose:
- Open dev tools → Network tab. Look at the preflight (OPTIONS) and the actual request.
- Check the OPTIONS response headers. Are they correct?
- Compare
Originheader withAccess-Control-Allow-Originresponse header. Exact match required (modulo trailing slash gotchas). - If using credentials, check
Allow-Credentials: trueAND specific origin (not*). - Check
Vary: Originis set if your config is per-origin.
* everywhere “works” until you turn on credentials; then everything breaks.
When CORS is overkill
Server-to-server APIs (no browser involved): don’t bother with CORS. Use IP allowlists, mTLS, JWT auth. CORS adds nothing.
Internal APIs only called from the same origin frontend: still configure CORS strict (only that origin); reject Allow-Origin: * for safety.
Interview angle
- “What does CORS actually protect?” — the browser from leaking cross-origin data. Server doesn’t enforce CORS; the browser does. Non-browser clients (curl, mobile, server-to-server) ignore CORS.
- “What’s wrong with
Allow-Origin: *andAllow-Credentials: true?” — explicitly disallowed by the spec; browsers reject. Either drop credentials or echo a specific origin from an allowlist. - “Why is reflecting
request.originintoAccess-Control-Allow-Origindangerous?” — every origin (including attacker.com) gets approved. Combined with credentials, an attacker site can make credentialed requests and read responses. Use a strict allowlist. - “What’s the preflight and when does it happen?” — OPTIONS request the browser sends before non-simple requests (custom headers, methods like PUT/DELETE, JSON content-type). Server responds with allowed methods/headers/origin; browser then sends the actual request.
- “CORS vs CSRF — are they the same?” — no. CORS controls who can read the response. CSRF controls who can trigger the request. CORS doesn’t prevent state-changing CSRF; use SameSite cookies + CSRF tokens for that.
- “Why does
Vary: Originmatter?” — without it, a CDN may cache the response with one client’sAllow-Originand serve it to another. Either over-permissive (wrong origin allowed) or broken (wrong origin rejected). SetVary: Originwhenever your config is per-origin. - “How would you implement subdomain wildcarding safely?” — strict regex (anchored, character-class restricted, scheme required). Don’t use loose patterns like
.*example.com.*. Or, better, enumerate all allowed origins explicitly.