CORS Deep
Same-origin policy, preflights, credentials, and the wildcard-vs-specific tension.
CORS Deep
The same-origin policy is the deepest defense the browser gives you. Without it, any page could read any other page — your bank, your email, your work apps. Same-origin says: code on attacker.com can't read responses from bank.com. Period.
CORS is the explicit relaxation of that rule. When you actually need cross-origin reads (a CDN, an API gateway, a separate-domain SPA), CORS is how you opt in. Get it right and your API plays well across origins. Get it wrong and you've handed the keys to the very policy you were trying to bypass.
Same-origin in detail
An origin is the tuple (scheme, host, port). https://app.example.com:443 and https://app.example.com are the same origin (port 443 default for https). http://app.example.com and https://app.example.com are different origins (scheme).
Same-origin policy applies to:
- Reading the response body of fetch / XHR /
<script>calls (you can send a cross-origin request — the browser will deliver it — but you can't read the response without CORS). - Reading DOM contents from a frame on a different origin.
- Reading localStorage / sessionStorage / cookies of another origin.
- Reading WebSocket messages from a different origin.
Some things are still allowed cross-origin without CORS:
- Forms that submit to other origins (this is what makes CSRF a thing).
<img src="other-origin">— loads, you just can't read the pixels (canvas-tainting).<script src="other-origin">— runs in your origin's context (this is how third-party JS works).
The two CORS request flavors
Simple requests
A request that doesn't trigger a preflight. Conditions:
- Method is
GET,HEAD, orPOST. - Content-Type is
text/plain,multipart/form-data, orapplication/x-www-form-urlencoded. - No custom headers beyond a CORS-safelisted set.
The browser sends the request directly with an Origin: https://attacker.example header. The server's response can include Access-Control-Allow-Origin — if it does, the browser lets the requesting JS read the body. If it doesn't, the request still happened (the server saw it, possibly mutated state) but the JS can't read the response.
This is why GET-mutations are dangerous: a simple cross-origin GET can change state even if the attacker can't read the response (CSRF territory).
Preflighted requests
Anything more complex: PUT/DELETE/PATCH, Content-Type: application/json, custom headers like Authorization: Bearer. The browser sends an OPTIONS request first:
OPTIONS /api/transfer HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Content-Type, Authorization
The server responds with what it allows:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST, GET, PUT, DELETE
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 600
If the actual request matches what the OPTIONS approved, the browser sends it. If not, it's blocked client-side. The preflight result is cached for Access-Control-Max-Age seconds.
The credential rules
Cookies, HTTP auth, and TLS client certs are all "credentials." For credential-bearing cross-origin requests:
- The fetch must opt in:
fetch(url, { credentials: "include" }). - The server must opt in:
Access-Control-Allow-Credentials: true. - The Allow-Origin must not be
*. The browser refuses to share credentials with a wildcard origin. The server has to echo a specific origin.
The whole credential-sharing dance exists for one reason: to avoid the worst-case CSRF/CORS combo, where any origin could read your authenticated API.
The misconfigs (in order of frequency)
1. Reflecting the Origin header
// On every request:
const origin = req.headers.origin;
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
This looks like a per-origin CORS config. It's actually a wildcard with credentials — any attacker page sets its origin, gets back its origin, and reads the response. Equivalent to opening up the API.
Fix: Check the request Origin against an explicit allowlist before echoing it.
2. Wildcard with credentials
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Browsers should refuse this combination. They mostly do. But:
- Older browsers and some HTTP libraries don't.
- The combination indicates a fundamental misunderstanding — fix the underlying config.
Fix: If you need credentials, you need a specific origin. If you need *, you must not need credentials.
3. null origin allowed
Origin: null is sent by sandboxed iframes, file:// URLs, redirects, opaque origins. Allowing null is allowing any sandboxed content — which an attacker can create. Some servers naively allowlist null because it appeared in their logs.
Fix: Never allowlist null. If a legitimate request appears as null, the upstream (the iframe's parent, the redirect chain) is the problem.
4. Trusting subdomains broadly
*.example.com (wildcard subdomain) sounds reasonable until any subdomain is compromised — app-old.example.com, intern-dashboard.example.com, that one S3 bucket subdomain that got abandoned. Now that subdomain can read your API.
Fix: Allowlist specific subdomains. Yes, it's more config; that's the point.
5. Long Max-Age leaving misconfigs sticky
Access-Control-Max-Age: 86400 caches preflight results for a day. If you ship a misconfig and roll it back, browsers continue to use the cached "allow" until expiry.
Fix: Cap Max-Age at 600 (10 minutes). Use longer values only after you're confident the policy is right.
Server-side rules of thumb
- Public read-only API:
Access-Control-Allow-Origin: *, no credentials. This is genuinely safe. - Authenticated API on a separate domain: explicit allowlist, credentials enabled, validate Origin against allowlist on every request.
- Authenticated API on same domain as the SPA: don't use CORS at all. Your SPA's same-origin requests are simpler and safer.
- Webhook receivers: don't accept cross-origin browser requests at all — return CORS headers that refuse them.
- Internal admin APIs: never allow CORS from external origins. Use VPN or BeyondCorp-style auth.
A working example
Strict CORS allowlist with credentials:
import cors from "cors";
const ALLOWED = new Set([
"https://app.example.com",
"https://staging.app.example.com",
]);
app.use(cors({
origin: (origin, cb) => {
// origin is undefined for same-origin / curl / server-to-server
if (!origin) return cb(null, false);
if (ALLOWED.has(origin)) return cb(null, origin);
cb(new Error("CORS: origin not allowed"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE"],
allowedHeaders: ["Content-Type", "Authorization"],
maxAge: 600,
}));
Three properties:
- The allowlist is a
Setliteral — easy to read, easy to audit. credentials: trueis paired with origin-specific echoing — never*.- Max-Age is bounded — misconfigs resolve in 10 minutes.
What CORS is NOT
CORS is a browser-enforced relaxation of same-origin policy. It is not:
- A defense against
curlor other non-browser clients. They ignore CORS entirely. CORS does not authenticate the request — it only governs what client-side JS in a browser can do with the response. - A replacement for authentication. The Origin header can be omitted, faked, or null. Never use Origin alone for authorization.
- A replacement for CSRF protection. CORS doesn't prevent simple cross-origin POSTs from firing — it only stops the JS from reading the response. CSRF defenses (SameSite, anti-CSRF tokens) are separate.
In production
When you ship a new CORS-enabled endpoint:
- Specific allowlist, no wildcards. No reflection.
- Credentials only if you need them — and only with a specific origin.
nullis never allowed.- Subdomains are listed individually, not wildcarded.
Vary: Originon every response so caches don't serve the wrong policy.- Max-Age ≤ 600 until policy is stable.
- Test cross-origin from a known-bad origin in CI; expect a CORS error.
CORS is one of those features where the wrong default is "permissive." The right default is "restrictive, then opt in only what you need." Your future self will thank you when the next OWASP top-ten cross-origin vulnerability comes for the rest of the industry.
Tools in the wild
3 tools- librarycors (express middleware)free tier
Standard Express CORS middleware — strict allowlist support out of the box.
- libraryfastapi.middleware.corsfree tier
FastAPI's built-in CORS middleware — config-driven allowlist.
- specMDN — Same-origin policyfree tier
The browser's default. CORS is the explicit relaxation.