How HTTP Works

Response header

< Access-Control-Expose-Headers: Content-Disposition, X-Request-Id

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.

Reviewed 3 min readintermediate4 sourcesTry itMarkdown
Direction
Response
Category
CORS
Spec
Fetch Standard
On this page

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

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/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

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 and the CORS guide. 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

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

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

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)?

Frequently asked questions

Why does response.headers.get() return null for a header I can see in DevTools?

On a cross-origin request the browser hands JavaScript only the CORS-safelisted response headers. DevTools shows every header the network layer received, but fetch() and XMLHttpRequest filter the rest. Add the header name to Access-Control-Expose-Headers on the response and it becomes readable.

Which response headers can JavaScript read without Access-Control-Expose-Headers?

Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma. That is the CORS-safelisted response-header list in the Fetch Standard. Everything else, including ETag, Location, Content-Disposition, Link, Retry-After and any X- header, is hidden until exposed.

Does Access-Control-Expose-Headers: * work with credentials?

No. The wildcard only applies to requests made without credentials. If the request uses credentials: include, cookies, or HTTP authentication, the browser treats * as a literal header name and exposes nothing extra. List each header explicitly.

Do I need Access-Control-Expose-Headers for same-origin requests?

No. The filtering applies only to CORS responses. Same-origin responses expose every header to scripts, with the usual exceptions of Set-Cookie and Set-Cookie2.

Can I expose Set-Cookie to JavaScript with Access-Control-Expose-Headers?

No. Set-Cookie and Set-Cookie2 are forbidden response-header names, and the browser never exposes them to scripts, whatever the server lists. If you need a value from a cookie, send it in a normal header or in the response body.

Is Access-Control-Expose-Headers sent on preflight responses?

It is not needed there. It is read from the actual response, so it must be on the GET, POST or other request that carries the data. Adding it only to the OPTIONS response does nothing.

Sources

  1. MDN Web Docs: Access-Control-Expose-Headersdeveloper.mozilla.org
  2. Fetch Standard: CORS-safelisted response-header namefetch.spec.whatwg.org
  3. Fetch Standard: HTTP responses (CORS protocol)fetch.spec.whatwg.org
  4. MDN Web Docs: CORSdeveloper.mozilla.org

Keep going

Browse /search