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-Remaining
    • X-Request-Id
    • Link for pagination
    • ETag for 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:

  1. Headers weren’t readable by frontend JavaScript
  2. Wildcard origin was too broad for future authenticated use
  3. 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-Headers and Allow-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 Link was unreadable
  • Returning rate limits in response payloads instead of headers
  • Ignoring ETag in the browser client
  • Random failures on authenticated requests due to preflight misconfig

After the fix:

  • Pagination used standard Link headers
  • 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-Headers for browser-readable metadata
  • Access-Control-Allow-Credentials: true only where needed
  • Vary: Origin on dynamic CORS responses
  • Explicit OPTIONS handling 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.