CORS — Cross-Origin Resource Sharing

The same-origin policy, preflight, Access-Control-Allow-*, credentials, the wildcard that breaks production.

Building Block Intermediate
9 min read
cors browser security preflight headers

What it is#

CORS is the browser’s mechanism for relaxing the same-origin policy, the foundational rule that a script loaded from one origin cannot read responses from a different origin. CORS lets the server explicitly opt in to cross-origin reads via a small set of response headers (Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers, Access-Control-Allow-Credentials, Access-Control-Expose-Headers).

CORS is not a server-side security mechanism. The server still receives the request and processes it; CORS only controls whether the browser hands the response body to the calling script. A curl request, a mobile app, or another server is not subject to CORS — they will see the response regardless. The protection CORS provides is purely browser-enforced isolation between JavaScript origins.

Two ideas do all the work:

  • Origin — the triple (scheme, host, port). https://app.example.com:443 and https://api.example.com:443 are different origins. http://example.com and https://example.com are different origins. Subdomains, ports, and schemes all count.
  • Preflight — for non-simple requests, the browser sends an OPTIONS request first to ask the server “may I send this?”, and only sends the real request if the server answers yes.

When to use it#

  • A browser frontend on origin A calls an API on origin B. This is the canonical case. app.example.com calls api.example.com. Without CORS, the browser refuses to expose the response to JavaScript.
  • A public API meant to be called from arbitrary browsers. Public read-only APIs (weather, geocoding) typically set Access-Control-Allow-Origin: * for unauthenticated requests.
  • A widget or embed that loads scripts cross-origin. OAuth redirect handlers, Stripe Elements, embedded chat widgets — all involve cross-origin requests CORS must permit.

CORS is not relevant when:

  • The caller is a server, a mobile app, or a CLI. They don’t honour CORS; the same-origin policy is a browser construct.
  • The caller is on the same origin. No CORS headers needed — the browser permits same-origin reads by default.
  • The API uses only cookies and the browser is on the same site. The Cookie + SameSite model handles CSRF independently.

How it works#

The browser splits cross-origin requests into two categories: simple and preflighted.

Simple requests#

A request is “simple” (no preflight) only if it meets all of these:

  • Method is GET, HEAD, or POST.
  • Content-Type is application/x-www-form-urlencoded, multipart/form-data, or text/plain. JSON does not qualify.
  • No custom headers beyond a small allowlist (Accept, Accept-Language, Content-Language, Range).
  • No ReadableStream body, no event listeners on XMLHttpRequest.upload.

For a simple request, the browser sends the request directly with an Origin header. The server must respond with Access-Control-Allow-Origin set to either the requesting origin or *. If absent or non-matching, the browser blocks the response from reaching JavaScript — the request was still sent and processed.

Simple cross-origin request
GET /api/v1/products HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
# Server responds:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Content-Type: application/json
{"products": [...]}

Preflighted requests#

Anything that isn’t simple triggers a preflight: a separate OPTIONS request the browser sends before the real one, asking permission.

Preflight (OPTIONS) request
OPTIONS /api/v1/orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization

The server must respond with:

Preflight response
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: 600

Access-Control-Max-Age tells the browser to cache the preflight result (here, 10 minutes). Without it, every POST triggers a fresh preflight — twice the round trips. Chrome caps the value at 2 hours; Firefox at 24 hours.

If the preflight succeeds, the browser sends the actual request with a normal Origin header. The server’s actual response must also carry Access-Control-Allow-Origin — the preflight permission alone is not enough.

Credentials (cookies, HTTP auth, client certs)#

By default, browsers strip Cookie, Authorization, and client certificates from cross-origin requests. The caller must opt in (credentials: "include" in fetch, withCredentials = true in XHR) and the server must opt in via Access-Control-Allow-Credentials: true.

Two extra rules apply when credentials are in play:

  • Access-Control-Allow-Origin must not be *. It must echo the exact origin (https://app.example.com).
  • Access-Control-Allow-Headers and Access-Control-Allow-Methods must not be * either — explicit lists only.

This is the most common production CORS bug: a developer sets Access-Control-Allow-Origin: * for “max compatibility”, then enables credentials, and the browser silently refuses every request. The fix is to reflect the validated Origin header back, after checking it against an allowlist.

Three-language CORS middleware#

The minimal correct configuration in three runtimes.

CORS — FastAPI / Starlette
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
app = FastAPI()
ALLOWED = {"https://app.example.com", "https://admin.example.com"}
app.add_middleware(
CORSMiddleware,
allow_origins=list(ALLOWED), # explicit list, never "*" with credentials
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE"],
allow_headers=["Content-Type", "Authorization"],
max_age=600,
)

Vary: Origin on the response is critical when the value of Access-Control-Allow-Origin is computed from the request. Without it, a CDN may cache the response for one origin and serve it to a script from another, defeating CORS entirely.

Variants#

Five extensions of the base CORS spec come up in production:

Header / mechanismWhat it does
Access-Control-Expose-HeadersWhitelist of response headers JavaScript can read. By default, JS can only read a small set (Cache-Control, Content-Language, Content-Type, etc.). Custom headers like X-Request-Id need to be explicitly exposed.
Access-Control-Allow-Private-NetworkLets a public site call a private-network resource (192.168.0.0/16, 127.0.0.1). New in 2022; Chrome enforces.
Timing-Allow-OriginLets the calling page read fine-grained timing data (DNS, TLS, TTFB) via the Resource Timing API.
Sec-Fetch-Site, Sec-Fetch-ModeFetch Metadata headers a server can use to decide whether to honour the request — a server-side complement to CORS.
Cross-Origin-Resource-Policy (CORP)A separate response header (CORP: same-origin / same-site / cross-origin) that complements CORS for non-script resource loads.

Trade-offs#

What CORS gives you:

  • A safe default for the web. Origins are isolated by default; cross-origin reads require opt-in. A vast amount of casual JS-driven scraping and XSRF is blocked by this baseline.
  • Granular per-route policy. A server can permit cross-origin reads of GET /products but not POST /orders.
  • Pre-cached preflights. Access-Control-Max-Age removes the per-request OPTIONS round trip for hot endpoints.

What it costs:

  • Operational complexity. Most production CORS bugs are configuration errors, not protocol gaps. The interaction between credentials, wildcard, and caching surprises every engineer at least once.
  • Per-route discipline. Every endpoint your browser frontend calls must be CORS-allowed for the right origin. Easy to forget on new routes.
  • CDN interaction. CDNs cache responses by URL by default; if the response varies by Origin, you must set Vary: Origin or the cache will serve mismatched headers.
  • Not a server-side defence. CORS does not replace AuthN/AuthZ/CSRF protection; it complements them. New engineers sometimes treat CORS as a security boundary it is not.

Common pitfalls#

  • Access-Control-Allow-Origin: * with credentials. The browser silently refuses every request. The spec mandates explicit origins with credentials. Always reflect an allowlisted origin instead.
  • Reflecting Origin without an allowlist. res.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin")) — this is the CSRF-style attack vector. Any origin sets a cookie and reads the response. Validate against an allowlist first.
  • Missing Vary: Origin. A CDN caches the response for origin A; origin B requests the same path and gets origin A’s Access-Control-Allow-Origin: https://a.com. CORS fails for B. Vary: Origin keys the cache by origin.
  • Forgetting the preflight on the actual route. The handler at /api/orders handles POST but returns 405 Method Not Allowed on OPTIONS. The preflight fails. Middleware should intercept OPTIONS before the route handler.
  • Allowing all headers without thinking. Access-Control-Allow-Headers: * works without credentials; with credentials it must be explicit. Misconfigured headers expose secrets like internal X-Tenant-Id.
  • Misunderstanding what CORS blocks. CORS does not block the request — it blocks the response. The server still ran the SQL. Don’t rely on CORS as a write-protection mechanism; use AuthN.
  • Per-port confusion in dev. http://localhost:3000 (web) calling http://localhost:8080 (api) is cross-origin because the port differs. Dev environments need CORS too.
  • Trailing-slash mismatches. Origin: https://example.com (no trailing slash); your allowlist has https://example.com/. Strict equality fails. Normalise.
  • HTTP-to-HTTPS upgrades. Origin: http://app.example.com will not match https://app.example.com. After your frontend gets HTTPS, update the CORS allowlist.
Search ESC

Keyboard shortcuts

Shortcuts are disabled while typing in inputs.