Debugging

Access-Control-Expose-Headers, explained

A server sends a custom header, it's right there in the DevTools Network tab, and response.headers.get(...) still returns null. The missing piece is Access-Control-Expose-Headers, the response-side counterpart to the request-side Access-Control-Allow-Headers we've covered before. We built two local servers, loaded them in a real Chromium instance, and checked exactly which headers fetch() can and can't see by default, with and without it set.

· ~6 min read

The seven headers fetch() can already read

A cross-origin fetch() response only exposes a short, fixed list of headers to JavaScript unless the server says otherwise: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, and Pragma. These are the Fetch standard's CORS-safelisted response headers. Anything outside that list, such as X-Total-Count, a rate-limit header, or an ETag, is present on the wire and visible in DevTools, but Headers.get() returns null for it from script.

This is easy to confuse with the better-known Access-Control-Allow-Headers error, but the two check opposite directions. Access-Control-Allow-Headers is about the preflight: which headers your request is allowed to send. Access-Control-Expose-Headers never triggers a preflight and never blocks anything; it only decides which response headers your own code is allowed to read. A missing Allow-Headers entry throws in the console. A missing Expose-Headers entry fails silently: no error, no warning, just null.

What we tested

We ran two plain Node HTTP servers on localhost, one acting as the API (port 8787, a different origin from the page) and one serving a page that calls it with fetch, then loaded that page in headless Chromium and read document.title back after the fetch resolved. The API's response carried a custom header:

res.writeHead(200, {
  'Access-Control-Allow-Origin': '*',
  'Content-Type': 'application/json',
  'X-Total-Count': '42'
});

With nothing else set, the page's own fetch call read back:

DONE:{"total":null,"ct":"application/json"}

Content-Type came through, because it's on the safelist. X-Total-Count did not, even though the same response, viewed with curl -i or in the Network tab, shows it plainly. Adding one header to the API server:

headers['Access-Control-Expose-Headers'] = 'X-Total-Count';

and reloading changed nothing else, only the fetch result:

DONE:{"total":"42","ct":"application/json"}

Setting Access-Control-Expose-Headers on a server you control

List every non-safelisted header the client needs to read, comma-separated. A plain Node server:

import { createServer } from 'node:http';

createServer((req, res) => {
  res.writeHead(200, {
    'Access-Control-Allow-Origin': 'https://app.example.com',
    'Content-Type': 'application/json',
    'X-Total-Count': '154',
    'Access-Control-Expose-Headers': 'X-Total-Count'
  });
  res.end(JSON.stringify({ items: [] }));
}).listen(3000);

Express, Fastify, Flask, and Django all expose the same option through whatever CORS middleware you're already using (exposedHeaders in cors for Express, expose_headers in flask-cors), rather than a header you set by hand next to your real ones; setting it only on some responses and not others is a common way to end up debugging this intermittently. A wildcard, Access-Control-Expose-Headers: *, also works, with one exception covered next.

The wildcard doesn't work with credentials

We repeated the test with fetch(url, { credentials: 'include' }) against a response that pinned Access-Control-Allow-Origin to the page's own origin, sent Access-Control-Allow-Credentials: true, and tried Access-Control-Expose-Headers: *:

DONE:{"total":null}

Still null. The same request with Access-Control-Expose-Headers: X-Total-Count, naming the header exactly instead of wildcarding it, read back "42". This mirrors the better-known rule that Access-Control-Allow-Origin: * can't pair with credentials either: a wildcard is treated as "no explicit opt-in" the moment cookies or credentials: 'include' are in play, covered in more detail in CORS with cookies and credentials. If your API needs both credentials and a custom header read client-side, the header name has to be spelled out.

What this means if you're behind a CORS proxy

corsproxy.dev doesn't set Access-Control-Expose-Headers on proxied responses, so calling it directly from browser JavaScript (the ?key= flow) gets you the same seven-header safelist as any other cross-origin fetch, not the upstream API's custom headers. That includes corsproxy.dev's own X-RateLimit-Limit, X-RateLimit-Remaining, and X-Cache headers, described in rate limits and quotas, explained: they're in the raw response, but response.headers.get('x-ratelimit-remaining') from a browser call returns null. You can't work around this with resHeaders either, since any access-control-* override is rejected on that parameter. If your code needs the upstream's custom headers, read them from a server-side call using X-API-Key instead of the browser flow, where you get the raw response and this restriction doesn't apply, or have the API in question return the value in the JSON body if it offers that option.

Debugging checklist