CORS Explained Without Cargo Culting
Preflights, credentials, allowed origins, and the mistakes that keep shipping to prod
Goal: help you configure cross-origin browser access correctly without turning CORS into accidental public access, broken cookies, or a week of “works in Postman” debugging.
TL;DR
- CORS is a browser access-control protocol, not an authentication or authorization system.
- CORS matters only when a browser tries to let JavaScript read a cross-origin response.
- Postman, curl, and server-to-server traffic do not enforce CORS.
- The safest default is: do not enable CORS unless a browser frontend on another origin actually needs it.
- If you do need it, allow specific origins, methods, and headers. Avoid
*unless the resource is truly public. - If you send cookies or HTTP auth, you must return
Access-Control-Allow-Credentials: trueand cannot useAccess-Control-Allow-Origin: *. - Preflight is just the browser asking permission before a non-simple request.
- CORS is not CSRF protection. Bad CORS can make a cookie-backed app easier to abuse.
1) What CORS Actually Is
CORS stands for Cross-Origin Resource Sharing.
It exists because browsers enforce the same-origin policy. By default, JavaScript running on https://app.example.com cannot freely read responses from https://api.example.net.
CORS is how the server tells the browser:
“This other origin is allowed to read my response.”
That means CORS is about browser-controlled reads, not about whether a request can physically reach your server.
Very important consequence
This request can still hit your server even if CORS is wrong:
curl https://api.example.com/users
So if someone says:
“We’re safe because CORS blocks it”
the answer is:
“Only in browsers, and only for scripts reading responses.”
Your backend must still do real authentication, authorization, and input validation.
2) Origin vs Site vs URL
People mix these up constantly.
An origin is:
- scheme (
https) - host (
app.example.com) - port (
443)
These are different origins:
https://app.example.comhttp://app.example.comhttps://api.example.comhttps://app.example.com:8443
CORS works at the origin level.
That means a frontend on:
https://www.example.com
calling an API on:
https://api.example.com
is cross-origin, even though both belong to the same company and may be the same “site” for cookie purposes.
3) When the Browser Sends a Preflight
A preflight is an OPTIONS request the browser sends before the real request when the real request is not “simple”.
Typical triggers for preflight
- method is not
GET,HEAD, orPOST - request includes non-safelisted headers like
AuthorizationorX-CSRF-Token Content-Typeis not one of the safelisted values like form-encoded, multipart form data, or plain text- request uses certain advanced features the browser treats as non-simple
Example
Frontend:
await fetch("https://api.example.com/orders", {
method: "POST",
headers: {
"content-type": "application/json",
"authorization": `Bearer ${token}`,
},
body: JSON.stringify({ sku: "abc", qty: 1 }),
});
Before the real request, the browser may send:
OPTIONS /orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: content-type, authorization
Your server must answer with something like:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: POST
Access-Control-Allow-Headers: content-type, authorization
Access-Control-Max-Age: 600
Vary: Origin
If that response is wrong, the browser blocks the real request from being exposed to JavaScript.
4) The Core Headers That Matter
Access-Control-Allow-Origin
Which origin may read the response.
Examples:
Access-Control-Allow-Origin: https://app.example.com
or for truly public resources:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials
Whether the browser may include credentials such as cookies or HTTP auth in a cross-origin request.
Access-Control-Allow-Credentials: true
There is no useful false value. Omit the header when credentials are not allowed.
Access-Control-Allow-Methods
Which methods may be used for the actual request after preflight.
Access-Control-Allow-Methods: GET, POST, PUT, DELETE
Access-Control-Allow-Headers
Which non-safelisted request headers are allowed.
Access-Control-Allow-Headers: content-type, authorization, x-csrf-token
Access-Control-Max-Age
How long the browser may cache the preflight result.
Access-Control-Max-Age: 600
Access-Control-Expose-Headers
Which response headers frontend JavaScript is allowed to read beyond the safelisted ones.
Access-Control-Expose-Headers: x-request-id, x-rate-limit-remaining
5) The Credentials Rule That Breaks Everyone Once
If the browser sends credentials, you need all of this:
- frontend must opt in with credentials
- server must return
Access-Control-Allow-Credentials: true - server must return a specific origin, not
*
Frontend example
await fetch("https://api.example.com/me", {
credentials: "include",
});
Correct server response
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Incorrect
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
That combination does not work in browsers.
Also important
Preflight requests themselves should not carry application credentials. They are permission checks, not authenticated business operations.
6) Public API vs Browser API: Two Different CORS Postures
Case A: Truly public, read-only resource
Example: public docs JSON, public image metadata, version manifest.
A permissive config can be fine:
Access-Control-Allow-Origin: *
Maybe no credentials, no sensitive data, no account-specific responses.
Case B: Browser app on another origin calling your API
Example:
- frontend:
https://app.example.com - API:
https://api.example.com
Use an allowlist:
const ALLOWED = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
Reflect the origin only if it is trusted.
Case C: Internal API consumed only server-to-server
Do not add CORS just because curl works and the framework made it easy.
No browser frontend on another origin means you probably do not need CORS at all.
7) The Most Common Bad Ideas
7.1 “Just set Access-Control-Allow-Origin: *”
Fine for public static resources. Bad for anything user-specific, authenticated, or sensitive.
7.2 “CORS secures the API”
No. CORS tells browsers which origins may read responses. Attackers can still call your API from their own servers.
7.3 “CORS errors mean the request never reached us”
Often false. The browser may have sent the preflight, or even the main request in some cases, and only blocked frontend access to the response. Check server logs.
7.4 “Allow all headers and methods forever”
Convenient, but you lose clarity and increase exposure. Keep the policy narrow.
7.5 “CORS and CSRF are the same problem”
They are not.
- CORS controls which origins may read responses.
- CSRF is about unwanted state-changing requests when the browser auto-sends credentials.
A cookie-backed app can have perfect CORS and still be vulnerable to CSRF. A bad CORS config can also make a cookie-backed API easier to call from trusted-but-too-broad frontend origins.
8) A Good Mental Model for Request Types
| Request type | Preflight? | Needs CORS? | Typical use |
|---|---|---:|---|
| Same-origin browser request | No | No | Frontend and API on same origin |
| Cross-origin browser GET with simple headers | Usually no | Yes | Public read APIs |
| Cross-origin browser POST JSON | Usually yes | Yes | SPA calling API |
| Cross-origin browser with cookies | Often yes | Yes, with credentials rules | BFF / session-backed APIs |
| curl / Postman / backend-to-backend | No browser CORS enforcement | No | Integration testing, service-to-service |
If you remember one thing, remember this:
CORS is a browser contract, not a perimeter firewall.
9) Practical Server Configs
Express / Node
import express from "express";
import cors from "cors";
const app = express();
const ALLOWED = new Set([
"https://app.example.com",
"https://admin.example.com",
]);
app.use(cors({
origin(origin, cb) {
// Allow non-browser or same-origin requests with no Origin header if needed.
if (!origin) return cb(null, true);
if (ALLOWED.has(origin)) return cb(null, true);
return cb(new Error("Origin not allowed by CORS"));
},
credentials: true,
methods: ["GET", "POST", "PUT", "PATCH", "DELETE"],
allowedHeaders: ["content-type", "authorization", "x-csrf-token"],
exposedHeaders: ["x-request-id"],
maxAge: 600,
}));
Nginx (specific origin, credentials)
map $http_origin $cors_origin {
default "";
"https://app.example.com" $http_origin;
"https://admin.example.com" $http_origin;
}
server {
location /api/ {
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Allow-Methods "GET, POST, PUT, PATCH, DELETE, OPTIONS" always;
add_header Access-Control-Allow-Headers "content-type, authorization, x-csrf-token" always;
add_header Access-Control-Max-Age 600 always;
add_header Vary Origin always;
return 204;
}
add_header Access-Control-Allow-Origin $cors_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Vary Origin always;
proxy_pass http://app_backend;
}
}
Important note
If you dynamically echo the allowed origin, add:
Vary: Origin
Otherwise shared caches may serve the wrong CORS response to the wrong caller.
10) Cookies, SPAs, and Why SameSite Still Matters
If your browser app uses cookies across origins or subdomains, CORS is only part of the picture.
You also need to think about:
- cookie
SameSite SecureHttpOnly- CSRF protection on state-changing endpoints
- whether you actually need cross-origin cookies at all
Safer default
Prefer:
- same-origin frontend + API when possible
- or a BFF pattern where the browser talks to one origin
This removes a lot of CORS pain and usually reduces auth complexity.
11) Debugging CORS Without Losing a Day
Step 1: open the browser network tab
Do not start with screenshots of the console error alone. Inspect:
- the Origin request header
- whether there was an OPTIONS preflight
- the response headers on both preflight and actual request
Step 2: answer these questions in order
- Is the request actually cross-origin?
- Did the browser send a preflight?
- Does the preflight response include the needed
Allow-*headers? - Are credentials involved?
- Is the server mistakenly sending
*with credentials? - Is a proxy/CDN stripping or caching headers?
- Did the request fail for a real backend reason and the frontend is blaming CORS?
Step 3: reproduce the preflight deliberately
curl -i -X OPTIONS 'https://api.example.com/orders' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,authorization'
This does not reproduce the browser perfectly, but it is great for checking your edge, gateway, or app response.
12) “Works in Postman” Is Not Evidence
Postman, curl, mobile apps, backend workers, and cron jobs do not enforce browser CORS policy.
So this situation is normal:
- API works in Postman
- browser app fails with CORS
That does not mean the API is fine for web usage. It means your browser-facing cross-origin policy is incomplete.
13) Sharp Edges You’ll Hit in Production
- Redirects on preflight can fail in surprising ways across browsers and proxies.
- 401/403 on OPTIONS often means auth middleware is running before CORS handling.
- CDN caching can serve stale or wrong
Access-Control-Allow-Originvalues ifVary: Originis missing. - Case mismatches in
Access-Control-Request-Headersvs your allowlist can confuse frameworks and gateways. - Wildcard subdomains sound convenient but easily become broader trust than intended.
- Third-party frontend previews like Vercel preview URLs and localhost ports can break allowlists unless you plan for them.
- Exposed headers are often forgotten, so frontend code cannot read useful response metadata even though the request succeeded.
14) Recommended Defaults
For public read-only assets
Access-Control-Allow-Origin: *
No credentials.
For production browser apps on a separate origin
- explicit allowlist of trusted origins
- explicit methods
- explicit request headers
Access-Control-Allow-Credentials: trueonly if neededVary: Origin- short but useful
Access-Control-Max-Age
For private APIs with no browser cross-origin use case
- no CORS at all
15) 2 AM Runbook
Symptom: frontend shows “blocked by CORS policy”.
- Open DevTools Network, not just Console.
- Check whether the request is actually cross-origin.
- Find the OPTIONS request if there is one.
- Confirm the response has the right:
Access-Control-Allow-OriginAccess-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Allow-Credentialsif needed
- If credentials are involved, verify you are not using
*for origin. - Confirm
OPTIONSis not blocked by auth middleware, WAF, or gateway rules. - Check CDN/proxy behavior and add
Vary: Originfor dynamic origin reflection. - Reproduce with curl using an explicit
Originheader. - If the response headers look right, inspect whether the actual backend request is failing for a normal reason like
401,403, or500. - Narrow the policy after the incident. Do not leave
*in place “until later”.
16) The Practical Bottom Line
Use CORS only where it solves a real browser problem.
When you need it:
- keep the policy specific
- understand preflights
- handle credentials carefully
- add
Vary: Originwhen reflecting origins - remember that auth and CSRF are separate concerns
The cleanest CORS config is often the one you never needed because your browser talks to one origin.