C.W.K.
Stream
Lesson 05 of 06 · published

CORS Deep Dive — Server-Declared Policy, Browser-Enforced

~12 min · auth-security, cors, preflight, browser

Level 0HTTP Newbie
0 XP0/46 lessons0/12 achievements
0/120 XP to next level120 XP to go0% complete
"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:

  1. 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.
  2. 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):

  1. Before the real request, the browser sends an OPTIONS request to the same URL with Origin, Access-Control-Request-Method, Access-Control-Request-Headers.
  2. Server responds with Access-Control-Allow-Origin/Methods/Headers declaring what's permitted.
  3. Browser compares. If the real request fits inside the declared policy, the browser proceeds. Otherwise, blocked.
  4. Real request sent; response also needs Access-Control-Allow-Origin on 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 to true if you want the browser to include cookies and Authorization headers. CRITICAL: cannot be combined with Access-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.
CORS is not security — it's a browser enforcement of a server policy. The protection isn't against malicious servers; it's against malicious websites trying to use your browser as a proxy for unauthorized access to other origins. A server that refuses cross-origin requests is locking down legitimate clients; a server that allows them must do real auth checks too — CORS alone is never the security boundary.

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

cwkPippa locks CORS tight: 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.

Code

The full CORS dance — preflight OPTIONS, then real request·http
# Preflight request (browser sends this BEFORE the actual POST)
OPTIONS /api/chat HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type

# Preflight response (server declares what's allowed)
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: authorization, content-type, x-request-id
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 86400

# Now the browser sends the actual POST (response also needs CORS headers)
POST /api/chat HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Authorization: Bearer abc.def.ghi
Content-Type: application/json

{"message":"hi pippa"}

HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Content-Type: application/json

{"reply":"hi 아빠"}
FastAPI CORSMiddleware — tight allowlist + credentials enabled·python
# FastAPI — CORSMiddleware handles preflight + actual request headers
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware

app = FastAPI()

# Tight allowlist — NO wildcards when using credentials
_allowed_origins = [
    'http://localhost:5173',
    'http://127.0.0.1:5173',
    'http://100.x.x.x:5173',  # Tailscale IP
]

app.add_middleware(
    CORSMiddleware,
    allow_origins=_allowed_origins,
    allow_credentials=True,                # cookies + Authorization allowed
    allow_methods=['*'],                   # GET, POST, PUT, DELETE, PATCH, OPTIONS
    allow_headers=['*'],                   # any request header
    expose_headers=['X-Request-ID'],       # response headers JS can read
    max_age=86400,                         # cache preflight for 24h
)

@app.get('/api/chat')
async def chat():
    return {'ok': True}
# OPTIONS /api/chat is handled automatically by CORSMiddleware
Client side — credentials: 'include' for cookies cross-origin·javascript
// Client side — fetch from a different origin
// CORS is the BROWSER's job; you don't 'enable' it in client code.
const resp = await fetch('https://api.example.com/api/chat', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'Authorization': 'Bearer abc.def.ghi',
  },
  credentials: 'include',  // send cookies — requires server to allow credentials
  body: JSON.stringify({ message: 'hi pippa' }),
});

// If the server's preflight response declares the right Origin/Methods/Headers,
// the browser allows the actual request and exposes the response.
// Otherwise: TypeError: NetworkError when attempting to fetch resource.
// Browser console will show the specific CORS check that failed.

External links

Exercise

Spin up a small FastAPI on port 8000 and a static HTML/JS on port 5500 (use python -m http.server 5500 from a directory with an index.html that does fetch('http://localhost:8000/data')). Watch the browser block the request. Then add CORSMiddleware to FastAPI with allow_origins=['http://localhost:5500'] and reload — the request succeeds. Bonus: add credentials: 'include' to the fetch, set CORSMiddleware's allow_credentials=True, and verify both still work. Then deliberately set allow_origins=['*'] while keeping credentials — watch the browser refuse the combination.
Hint
The same request that fails from a browser (CORS-blocked) succeeds when run from curl or httpx — that's the 'CORS is browser-only' lesson in your hands. Look at the browser's network tab; you'll see the actual OPTIONS preflight if the request triggers one (content-type: application/json does). Reading the failure message in the console teaches you more about CORS than any tutorial.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.