backend / security / 10_cors_deep.md

CORS — The Deep Dive

7 interview angles 6 min read source

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 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.

@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_regex exists 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: 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

  • “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.