Backend / Security / 10_cors_deep.md

CORS — The Deep Dive

Updated 7 interview angles 6 min read source
On this page9
  1. What CORS actually does
  2. The request flow
  3. Pitfalls
  4. FastAPI CORSMiddleware
  5. Django
  6. What about preflight performance?
  7. Common interview gotcha: “we enabled CORS but it’s still failing”
  8. When CORS is overkill
  9. Interview angle

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:

text
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:

text
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

text
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:

python
@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:

python
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:

python
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

python
# 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:

python
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 cookiesSameSite=Lax or SameSite=Strict on session cookies prevents them from being sent with cross-site requests.
  • Custom request headers — a header like X-Requested-With: XMLHttpRequest requires 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.

python
@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:

text
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

python
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_regex exists for pattern matching — apply the anchoring rules above.

Django

Use django-cors-headers:

python
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: 86400 caches 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:

  1. Open dev tools → Network tab. Look at the preflight (OPTIONS) and the actual request.
  2. Check the OPTIONS response headers. Are they correct?
  3. Compare Origin header with Access-Control-Allow-Origin response header. Exact match required (modulo trailing slash gotchas).
  4. If using credentials, check Allow-Credentials: true AND specific origin (not *).
  5. Check Vary: Origin is 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 7

  • “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: * and Allow-Credentials: true?” — explicitly disallowed by the spec; browsers reject. Either drop credentials or echo a specific origin from an allowlist.
  • “Why is reflecting request.origin into Access-Control-Allow-Origin dangerous?” — 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: Origin matter?” — without it, a CDN may cache the response with one client’s Allow-Origin and serve it to another. Either over-permissive (wrong origin allowed) or broken (wrong origin rejected). Set Vary: Origin whenever 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.