# Access-Control-Expose-Headers: Read Headers in fetch()

> Fix response.headers.get() returning null on cross-origin fetch. Access-Control-Expose-Headers, safelisted headers, and why * fails with credentials.

Source: https://howhttpworks.com/headers/access-control-expose-headers
Last reviewed: 2026-10-04

> **TL;DR:** A cross-origin `fetch()` can read only seven response headers by default. To read anything else (`Content-Disposition`, `ETag`, `Location`, a custom `X-Request-Id`), the server must list it in `Access-Control-Expose-Headers` on the actual response. `*` works only for requests without credentials.

## The symptom

```javascript
const res = await fetch('https://api.example.com/export', { mode: 'cors' })
console.log(res.headers.get('Content-Disposition')) // null
console.log(res.headers.get('X-Request-Id')) // null
console.log([...res.headers.keys()]) // ['cache-control', 'content-length', 'content-type']
```

There is no console error and nothing in the CORS handshake failed. The response arrived, DevTools shows `Content-Disposition` in the Network panel, and your code still gets `null`. The browser filters the response before JavaScript sees it.

## The fix

```http
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: Content-Disposition, X-Request-Id, ETag
Content-Disposition: attachment; filename="export.csv"
X-Request-Id: 4f2a91c0
ETag: "v17"
Vary: Origin
```

Syntax: a comma-separated list of header names, case-insensitive, or `*`. It must be on the response to the real request. Putting it only on the `OPTIONS` preflight response does nothing, because the browser reads it from the response whose headers the page will see.

## What is already readable

The Fetch Standard calls these CORS-safelisted response-header names, and they need no listing:

- `Cache-Control`
- `Content-Language`
- `Content-Length`
- `Content-Type`
- `Expires`
- `Last-Modified`
- `Pragma`

Headers people most often discover are hidden: `Content-Disposition` (filename for downloads), `ETag`, `Location` on a `201` or a manually handled redirect, `Link` (pagination), `Retry-After`, `X-RateLimit-*`, `X-Total-Count`, trace IDs, and a refreshed token header.

`Content-Length` is readable but often useless: with compression or chunked encoding it is absent or reflects encoded bytes.

## Wildcard and credentials

```http
Access-Control-Expose-Headers: *
```

Works only for requests that do not include credentials. With `fetch(url, { credentials: 'include' })`, `XMLHttpRequest.withCredentials = true`, or HTTP authentication, the browser treats `*` as a literal header name. Nothing is exposed and again nothing errors. This matches how `Access-Control-Allow-Headers` and `Access-Control-Allow-Methods` handle `*`; see [Access-Control-Allow-Credentials](https://howhttpworks.com/headers/access-control-allow-credentials) and the [CORS guide](https://howhttpworks.com/guides/cors). So for any API your SPA calls with cookies, spell the headers out.

`Set-Cookie` and `Set-Cookie2` can never be exposed, listed or not.

## Server configuration

### Express

```javascript
import cors from 'cors'

app.use(
  cors({
    origin: 'https://app.example.com',
    credentials: true,
    exposedHeaders: ['Content-Disposition', 'X-Request-Id', 'ETag']
  })
)
```

Without the `cors` package: `res.set('Access-Control-Expose-Headers', 'Content-Disposition, X-Request-Id')`.

### nginx

```nginx
location /api/ {
    add_header Access-Control-Expose-Headers "Content-Disposition, X-Request-Id, ETag" always;
    proxy_pass http://app;
}
```

`always` is needed so the header is also present on 4xx and 5xx responses; otherwise an error response hides its request ID from your front end. If the location block already has other `add_header` lines or the CORS headers are set in the app, make sure only one layer sets `Access-Control-Expose-Headers`. Two layers each sending it produces two header lines that browsers combine, which is valid but hard to debug.

### Apache

```apache
Header always set Access-Control-Expose-Headers "Content-Disposition, X-Request-Id, ETag"
```

## Checklist when a header is still null

1. Is the request actually cross-origin? Same-origin needs nothing.
2. Is the header on the real response, not just the preflight? `curl -i -H 'Origin: https://app.example.com' https://api.example.com/export`.
3. Does the name match? Names are case-insensitive, but typos and underscores are not forgiven.
4. Are you sending credentials with `*`? List the names.
5. Is a CDN or gateway between you and the origin stripping or caching a response made without an `Origin` header? Set `Vary: Origin`.
6. Is the header one that is never exposed (`Set-Cookie`)?

## Related

- [Access-Control-Allow-Origin](https://howhttpworks.com/headers/access-control-allow-origin), [Access-Control-Allow-Headers](https://howhttpworks.com/headers/access-control-allow-headers) (which controls *request* headers, not response headers)
- [Access-Control-Allow-Credentials](https://howhttpworks.com/headers/access-control-allow-credentials)
- [CORS guide](https://howhttpworks.com/guides/cors)
