CORS — Cross-Origin Resource Sharing
The same-origin policy, preflight, Access-Control-Allow-*, credentials, the wildcard that breaks production.
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:443andhttps://api.example.com:443are different origins.http://example.comandhttps://example.comare different origins. Subdomains, ports, and schemes all count. - Preflight — for non-simple requests, the browser sends an
OPTIONSrequest 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.comcallsapi.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, orPOST. Content-Typeisapplication/x-www-form-urlencoded,multipart/form-data, ortext/plain. JSON does not qualify.- No custom headers beyond a small allowlist (Accept, Accept-Language, Content-Language, Range).
- No
ReadableStreambody, no event listeners onXMLHttpRequest.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.
GET /api/v1/products HTTP/1.1Host: api.example.comOrigin: https://app.example.com
# Server responds:HTTP/1.1 200 OKAccess-Control-Allow-Origin: https://app.example.comContent-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.
OPTIONS /api/v1/orders HTTP/1.1Host: api.example.comOrigin: https://app.example.comAccess-Control-Request-Method: POSTAccess-Control-Request-Headers: content-type, authorizationThe server must respond with:
HTTP/1.1 204 No ContentAccess-Control-Allow-Origin: https://app.example.comAccess-Control-Allow-Methods: GET, POST, PUT, DELETEAccess-Control-Allow-Headers: content-type, authorizationAccess-Control-Max-Age: 600Access-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-Originmust not be*. It must echo the exact origin (https://app.example.com).Access-Control-Allow-HeadersandAccess-Control-Allow-Methodsmust 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.
from fastapi import FastAPIfrom 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,)package main
import "net/http"
var allowed = map[string]bool{ "https://app.example.com": true, "https://admin.example.com": true,}
func cors(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") if allowed[origin] { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Vary", "Origin") // critical for caching w.Header().Set("Access-Control-Allow-Credentials", "true") } if r.Method == "OPTIONS" { w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Max-Age", "600") w.WriteHeader(http.StatusNoContent) return } next.ServeHTTP(w, r) })}import express from "express";
const ALLOWED = new Set([ "https://app.example.com", "https://admin.example.com",]);
const app = express();
app.use((req, res, next) => { const origin = req.headers.origin; if (origin && ALLOWED.has(origin)) { res.setHeader("Access-Control-Allow-Origin", origin); res.setHeader("Vary", "Origin"); res.setHeader("Access-Control-Allow-Credentials", "true"); } if (req.method === "OPTIONS") { res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE"); res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); res.setHeader("Access-Control-Max-Age", "600"); return res.sendStatus(204); } next();});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 / mechanism | What it does |
|---|---|
Access-Control-Expose-Headers | Whitelist 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-Network | Lets a public site call a private-network resource (192.168.0.0/16, 127.0.0.1). New in 2022; Chrome enforces. |
Timing-Allow-Origin | Lets the calling page read fine-grained timing data (DNS, TLS, TTFB) via the Resource Timing API. |
Sec-Fetch-Site, Sec-Fetch-Mode | Fetch 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 /productsbut notPOST /orders. - Pre-cached preflights.
Access-Control-Max-Ageremoves 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 setVary: Originor 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
Originwithout 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’sAccess-Control-Allow-Origin: https://a.com. CORS fails for B.Vary: Originkeys the cache by origin. - Forgetting the preflight on the actual route. The handler at
/api/ordershandlesPOSTbut returns405 Method Not AllowedonOPTIONS. The preflight fails. Middleware should interceptOPTIONSbefore 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 internalX-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) callinghttp://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 hashttps://example.com/. Strict equality fails. Normalise. - HTTP-to-HTTPS upgrades.
Origin: http://app.example.comwill not matchhttps://app.example.com. After your frontend gets HTTPS, update the CORS allowlist.
Related building blocks#
- API Security — An Overview — where CORS sits in the browser-security model.
- Transport Layer Security (TLS) — CORS assumes TLS; mixed HTTP/HTTPS origins are a common configuration trap.
- Securing APIs Using Input Validation — server-side defence that complements CORS; CORS does not validate request bodies.
- API Security — A High-Level Recap — the full checklist; CORS is one box for browser callers.
- HTTP — The Foundational Protocol for APIs — the
OPTIONSmethod andOriginheader that CORS is built on.