Access-Control-Expose-Headers: why response.headers.get() returns null (tested)
On a cross-origin response the server can send any header, but your JavaScript only sees a short safe list unless the server exposes more. We read every header in Chrome with and without Access-Control-Expose-Headers.
1. What the page could read
| Header on the response | No expose header | Content-Disposition, X-Total-Count | * |
|---|---|---|---|
| Content-Type, Cache-Control, Content-Language | visible | visible | visible |
| Content-Disposition | null | visible | visible |
| X-Total-Count (custom) | null | visible | visible |
| Retry-After | null | null (not listed) | visible |
Without the header, [...response.headers.keys()] listed only cache-control, content-language, content-type. DevTools' Network tab still shows every header, which is why this looks like a browser bug: the header arrived, the page just isn't allowed to read it.
2. The fix
Access-Control-Expose-Headers: Content-Disposition, Retry-After, X-Total-Count
# nginx
add_header Access-Control-Expose-Headers "Content-Disposition, Retry-After, X-Total-Count" always;
Typical cases: reading the download file name from Content-Disposition, honouring Retry-After on a 429, pagination totals (X-Total-Count, Link), and rate-limit headers. * exposed everything in our test, but per the Fetch spec it is treated as a literal header name on requests with credentials (cookies), so list names explicitly there.
3. Expose-Headers vs Allow-Headers
Access-Control-Allow-Headers: request headers the page may send (answered in the preflight; measured with X-Requested-With).Access-Control-Expose-Headers: response headers the page may read. It goes on the actual response, not just the preflight.
Frequently asked questions
Why does response.headers.get() return null in fetch?
The response is cross-origin and the header isn't exposed. In our Chrome test Content-Disposition, Retry-After and X-Total-Count were null until listed in Access-Control-Expose-Headers.
Which response headers can JavaScript read without exposing them?
The CORS-safelisted ones. Our test saw Cache-Control, Content-Language and Content-Type by default (the spec also lists Content-Length, Expires, Last-Modified and Pragma).
Does Access-Control-Expose-Headers: * work?
For requests without credentials, yes: it exposed every header in our test. With cookies, the spec treats * literally, so list the names.
What's the difference between Access-Control-Allow-Headers and Expose-Headers?
Allow-Headers is for request headers the page sends; Expose-Headers is for response headers the page reads.
Written with AI assistance. Measured on 2026-10-01 in Chromium 153 (full build): a page on localhost:8994 fetched a cross-origin URL whose response carried Content-Disposition, Retry-After, X-Total-Count, Cache-Control and Content-Language, with three Access-Control-Expose-Headers values; we read response.headers in the page. The rule that '*' doesn't work for credentialed requests is from the Fetch spec, not this test; so are the DevTools remark and the nginx line. PlainHeaders is our extension; the other methods are free.