A lot of teams treat CORS like a checkbox: add Access-Control-Allow-Origin, ship it, move on. That usually works right up until the frontend needs one custom header, auth cookies enter the picture, or someone decides * is fine everywhere.
I’ve seen this go wrong in a very normal setup: a React frontend on app.example.com, an API on api.example.com, and a CDN in front of both. Nothing exotic. The bug report sounded simple:
“Frontend can call the API, but it can’t read pagination headers or rate limit info.”
The team had “enabled CORS.” Technically true. Functionally broken.
This is the kind of problem that slips through because requests succeed, but the browser still hides useful response metadata from JavaScript. If you build APIs for browser clients, CORS is part of your security header strategy, not just a routing detail.
The setup
The app had:
- Frontend:
https://app.acme.test - API:
https://api.acme.test - Session-based admin endpoints using cookies
- Public GET endpoints used by the customer dashboard
- A few custom headers:
X-RateLimit-RemainingX-Request-IdLinkfor paginationETagfor caching
The backend team had this Nginx config:
location /api/ {
add_header Access-Control-Allow-Origin *;
}
That was the whole “CORS policy.”
At first glance, it seemed fine. The browser made the request, got a 200 OK, and the JSON body was readable. But the frontend code failed here:
const res = await fetch("https://api.acme.test/api/orders");
const remaining = res.headers.get("X-RateLimit-Remaining");
const link = res.headers.get("Link");
console.log({ remaining, link });
// { remaining: null, link: null }
Developers often assume “if I can see the header in DevTools, JavaScript can read it.” Wrong. The browser may receive the header, show it in the network panel, and still block access from fetch() unless you explicitly expose it.
That distinction matters.
What was actually broken
The API returned useful headers, but didn’t send Access-Control-Expose-Headers.
Without that header, browser JavaScript can only access a small safelisted set of response headers. Your custom headers and many standard-but-useful ones stay invisible.
GitHub’s API is a good real-world reference because it gets this right. api.github.com returns:
access-control-allow-origin: *
access-control-expose-headers: ETag, Link, Location, Retry-After, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Used, X-RateLimit-Resource, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval, X-GitHub-Media-Type, X-GitHub-SSO, X-GitHub-Request-Id, Deprecation, Sunset, Warning
That header list tells browser clients exactly which non-safelisted headers they’re allowed to read. It’s not decorative. It’s the difference between an API that works in the browser and one that only half works.
Before: permissive and incomplete
Here’s a simplified version of what the team had in Express:
app.use((req, res, next) => {
res.setHeader("Access-Control-Allow-Origin", "*");
next();
});
app.get("/api/orders", (req, res) => {
res.setHeader("X-RateLimit-Remaining", "42");
res.setHeader("X-Request-Id", "req_123");
res.setHeader("Link", '</api/orders?page=2>; rel="next"');
res.json([{ id: 1 }, { id: 2 }]);
});
This caused three separate problems:
- Headers weren’t readable by frontend JavaScript
- Wildcard origin was too broad for future authenticated use
- The team assumed CORS was “done” and never tested preflight behavior
That second point is where teams get burned later. Access-Control-Allow-Origin: * is okay for truly public, unauthenticated resources. It is not okay when you need cookies or other credentials. Once the app added authenticated cross-origin admin calls, this policy had to be ripped out.
After: explicit, boring, correct
We split the API into two policy buckets:
- Public read-only endpoints: wide access, no credentials
- Authenticated endpoints: allowlisted origin, credentials enabled
Public endpoints
app.get("/api/public/orders", (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "https://app.acme.test");
res.setHeader(
"Access-Control-Expose-Headers",
"ETag, Link, X-RateLimit-Remaining, X-Request-Id"
);
res.setHeader("ETag", '"orders-v1"');
res.setHeader("X-RateLimit-Remaining", "42");
res.setHeader("X-Request-Id", "req_123");
res.setHeader("Link", '</api/public/orders?page=2>; rel="next"');
res.json([{ id: 1 }, { id: 2 }]);
});
Frontend code started working immediately:
const res = await fetch("https://api.acme.test/api/public/orders");
console.log(res.headers.get("ETag")); // "orders-v1"
console.log(res.headers.get("Link")); // </api/public/orders?page=2>; rel="next"
console.log(res.headers.get("X-RateLimit-Remaining")); // 42
Authenticated endpoints
For cookie-based admin endpoints, we had to be stricter:
app.use("/api/admin", (req, res, next) => {
const origin = req.headers.origin;
if (origin === "https://app.acme.test") {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
res.setHeader(
"Access-Control-Expose-Headers",
"X-Request-Id"
);
}
next();
});
app.options("/api/admin/*", (req, res) => {
const origin = req.headers.origin;
if (origin === "https://app.acme.test") {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-CSRF-Token");
res.setHeader("Access-Control-Max-Age", "600");
res.setHeader("Vary", "Origin");
}
res.sendStatus(204);
});
This fixed a few subtle issues:
- Cookies now worked cross-origin because we stopped using
* - Preflight requests succeeded for JSON and CSRF-protected requests
- Caches wouldn’t mix responses between origins because of
Vary: Origin
That Vary header gets missed all the time. If your CDN or proxy caches CORS responses and you reflect the request origin, Vary: Origin is non-negotiable.
The security header angle people miss
CORS is a browser-enforced access control layer for frontend JavaScript. It’s not an auth mechanism, and it’s not a substitute for CSRF protection, session controls, or content security policy.
Still, it belongs in the same conversation as your other web security headers because bad defaults create real exposure:
Access-Control-Allow-Origin: *on endpoints that later start using credentials- Overly broad
Access-Control-Allow-HeadersandAllow-Methods - Missing
Vary: Origin - Forgetting
Access-Control-Expose-Headers, which breaks legitimate frontend behavior - Assuming CORS protects server-to-server traffic, which it does not
When I audit headers, I usually check CORS alongside HSTS, CSP, X-Content-Type-Options, and framing protections. If you want a broader header baseline beyond CORS, csp-guide.com is a solid reference for the CSP side of things.
For quick verification, a tool like HeaderTest is handy because it shows what you actually shipped, not what you think your framework middleware is doing.
What changed for the frontend team
Before the fix, they had workarounds everywhere:
- Duplicating pagination info in the JSON body because
Linkwas unreadable - Returning rate limits in response payloads instead of headers
- Ignoring
ETagin the browser client - Random failures on authenticated requests due to preflight misconfig
After the fix:
- Pagination used standard
Linkheaders - Browser code could read rate-limit and request tracing info
- Caching behavior improved with
ETag - Admin flows stopped failing on
OPTIONS
That’s the boring outcome you want. No custom hacks, no “special browser mode,” no mystery nulls in response.headers.
Rules I’d keep for any production app
Here’s the short version I’d hand to any team:
1. Don’t use * unless the resource is truly public
If there’s any chance the endpoint will need cookies, tenant-specific data, or auth later, start with an allowlist now.
2. Expose the headers your frontend actually needs
If the client reads ETag, Link, or rate-limit headers, add Access-Control-Expose-Headers. Otherwise the browser hides them.
3. Add Vary: Origin when origin-specific responses are possible
Especially behind a CDN.
4. Treat preflight as a first-class path
Test OPTIONS explicitly. Don’t assume your framework handled it correctly.
5. Keep CORS narrow
Allowed origins, methods, and request headers should all be deliberate. Wide-open CORS is usually laziness disguised as compatibility.
A practical before-and-after checklist
Before
Access-Control-Allow-Origin: *- No
Access-Control-Expose-Headers - No
Access-Control-Allow-Credentials - No
Vary: Origin - No explicit preflight handler
After
- Explicit origin allowlist
Access-Control-Expose-Headersfor browser-readable metadataAccess-Control-Allow-Credentials: trueonly where neededVary: Originon dynamic CORS responses- Explicit
OPTIONShandling with allowed methods and headers
CORS problems usually don’t look like security incidents. They look like weird frontend bugs, null header values, broken auth flows, and developers stuffing protocol metadata into JSON because the browser won’t let them read headers.
That’s why I like to treat CORS as part of the app’s security header design, not an afterthought. When you configure it with intent, browser clients get the data they need, and your attack surface stays smaller. That’s a better trade than * and hope.