GuidesACCESS-CONTROL-EXPOSE-HEADERS

Access-Control-Expose-Headers: read response headers in fetch

A cross-origin fetch() can read only 7 response headers unless the server lists more in Access-Control-Expose-Headers. Any other header, such as X-Total-Count, Link, ETag or Content-Disposition, comes back as null from response.headers.get() with no error, even though DevTools shows it. The fix is on the server: send Access-Control-Expose-Headers: X-Total-Count, Link on the actual response, not only on the preflight. We tested every case below in Chromium 153, Firefox 155 and WebKit 26.6 on 26 October 2026.

What Access-Control-Expose-Headers does

Once a cross-origin response passes the Access-Control-Allow-Origin check, your script can read the status and the body, even on a 500, but the browser filters response.headers down to the 7 CORS-safelisted response headers. [...response.headers.keys()], forEach() and entries() list only those, and so do XMLHttpRequest.getAllResponseHeaders() and axios 1.20.0 in the browser. Content-Length is one of the 7, but it holds the compressed size on a gzipped response and is null on a chunked one.

Access-Control-Expose-Headers adds names to that list. It takes a comma-separated, case-insensitive list of header names and must be on the actual response. Sending Access-Control-Expose-Headers: X-Total-Count, Link, ETag, Content-Disposition, X-RateLimit-Remaining made all 5 readable in all 3 engines. It works in the opposite direction from Access-Control-Allow-Headers, which controls the request headers your code may send.

Response headers a cross-origin fetch() can read without Access-Control-Expose-Headers in Chromium 153, Firefox 155 and WebKit 26.6, checked October 2026
HeaderReadable without exposureNotes
Cache-ControlYesCORS-safelisted
Content-LanguageYesCORS-safelisted
Content-LengthYesCompressed size when gzipped, null when the body is chunked
Content-TypeYesCORS-safelisted
ExpiresYesCORS-safelisted
Last-ModifiedYesCORS-safelisted
PragmaYesCORS-safelisted
ETagNoGitHub, GitLab and Bitbucket expose it; PokeAPI and DummyJSON send it without exposing it
LinkNoPagination; GitHub, GitLab, WordPress, Codeberg and Mastodon expose it
X-Total-CountNoJSONPlaceholder, Codeberg and json-server 0.17 expose it; json-server 1.0 beta no longer sends it
X-WP-TotalNoThe WordPress REST API exposes it together with X-WP-TotalPages and Link
X-RateLimit-RemainingNoGitHub, Mastodon and OpenAlex expose it; DummyJSON does not
Content-DispositionNoExpose it to read a download filename
Date and ServerNoNull unless exposed, even though DevTools shows them
Set-CookieNeverNull even when listed, with the wildcard, or on the same origin

Why headers.get() still returns null

fetch() never warns about a hidden header; only XMLHttpRequest in Chromium logs "Refused to get unsafe header". These are the causes we reproduced:

  • Not exposed at all: the server sends the header and Access-Control-Allow-Origin, but no Access-Control-Expose-Headers that names it.
  • Exposed only on the preflight: the header is on the OPTIONS preflight response but missing from the GET or POST response. That exposes nothing in all 3 engines.
  • Wrong separator or quotes: X-Total-Count Link (space) and X-Total-Count; Link (semicolon) expose nothing. Quoting a name, as in "X-Total-Count", Link, makes the browser ignore the whole header. A trailing comma is fine, and two separate header lines are combined.
  • nginx add_header without always: nginx adds headers only to status 200, 201, 204, 206, 301, 302, 303, 304, 307 and 308 unless you add always. In our nginx 1.26.3 test a 429 response lost both CORS headers.
  • An add_header inside a location block: nginx inherits add_header from the outer block only when the current block has none of its own, so a single add_header in a location drops the server-level CORS headers.
  • Access-Control-Allow-Origin sent twice: when nginx and the app both set it, every engine rejects the response. Chromium says the header "contains multiple values", WebKit says it "cannot contain more than one origin", and Firefox fails with a NetworkError.
  • Set-Cookie: never readable from fetch(), even when listed, with *, or on the same origin. getSetCookie() returns [].
  • The wildcard with credentials: * exposes nothing extra once the request uses credentials: 'include'.

DevTools showed all 19 header names our test server sent while the script could read 7, so the Network panel tells you what arrived, not what your code can read. The CORS tester sends a live request from your browser and lists only the headers JavaScript can see.

The * wildcard and credentials

Without credentials, the fetch() default for cross-origin requests, Access-Control-Expose-Headers: * exposes every header except Set-Cookie, including Date and Server, in all 3 engines.

With credentials: 'include', the server must answer with the exact origin and Access-Control-Allow-Credentials: true, because Access-Control-Allow-Origin: * fails the whole request. In that mode * in Access-Control-Expose-Headers is just a header name: only a header literally called * became readable, while X-Total-Count and Link stayed null. Sending *, X-Total-Count made X-Total-Count readable, so list every name when you send credentials.

Pagination and rate-limit headers from real APIs

Many public APIs already expose what you need. api.github.com sends Access-Control-Allow-Origin: * and exposes 19 headers, including ETag, Link, Retry-After and the X-RateLimit-* family, with an anonymous limit of 60 requests per hour (see GitHub API rate limits). In all 3 browsers fetch() read Link, X-RateLimit-Remaining and ETag directly, while Server stayed null. GitLab exposes Link, X-Total, X-Total-Pages and ETag, Codeberg exposes Link and X-Total-Count, and Mastodon exposes Link and X-RateLimit-Remaining.

The WordPress REST API (/wp-json/wp/v2/posts) exposes X-WP-Total, X-WP-TotalPages and Link. JSONPlaceholder sends X-Total-Count: 100 on /posts?_page=1&_limit=2 and exposes it, so call it directly. json-server 0.17.4 sends and exposes X-Total-Count and Link for _page and _limit queries, but 1.0.0-beta.15, the current npm release, sends no X-Total-Count at all and returns the totals in the JSON body (items, pages, data) for _page and _per_page. If your total count vanished after an upgrade, that is why.

Read Link and X-RateLimit-Remaining from the GitHub API
const response = await fetch('https://api.github.com/repos/nodejs/node/issues?per_page=5');

const next = response.headers.get('Link')?.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null;

console.table({
  status: response.status,
  next,
  remaining: response.headers.get('X-RateLimit-Remaining'),
  etag: response.headers.get('ETag'),
  server: response.headers.get('Server'),
});
console.log([...response.headers.keys()]);

Expose headers on your own server

We ran each of these locally on 26 October 2026 and checked the response with curl:

  • Express 5.3.0 with cors 2.8.6: cors({ exposedHeaders: ['X-Total-Count', 'Link'] }) sends Access-Control-Expose-Headers: X-Total-Count,Link. More in the Express guide.
  • Flask 3.1.3 with Flask-CORS 6.0.5: CORS(app, expose_headers=[...]), see the Flask guide.
  • Django 6.1.2 with django-cors-headers 4.9.0: CORS_EXPOSE_HEADERS in settings.py, sent only to origins in CORS_ALLOWED_ORIGINS (Django guide).
  • nginx 1.26.3: add_header ... always, with all CORS add_header lines at the same level.
  • Amazon S3: "ExposeHeaders": ["ETag"] in the bucket CORS rule; browser multipart uploads need it to read each part's ETag (S3 guide).
Expose X-Total-Count, Link and Content-Disposition on your server
// Express 5 + cors 2.8
app.use(cors({
  origin: 'https://app.example.com',
  exposedHeaders: ['X-Total-Count', 'Link', 'Content-Disposition'],
}));

# Flask + Flask-CORS 6
CORS(app, origins=["https://app.example.com"],
     expose_headers=["X-Total-Count", "Link", "Content-Disposition"])

# Django + django-cors-headers 4 (settings.py)
CORS_ALLOWED_ORIGINS = ["https://app.example.com"]
CORS_EXPOSE_HEADERS = ["X-Total-Count", "Link", "Content-Disposition"]

# nginx: "always" keeps the headers on 4xx and 5xx responses.
# Set CORS headers in nginx or in the app, never in both.
add_header Access-Control-Allow-Origin "https://app.example.com" always;
add_header Access-Control-Expose-Headers "X-Total-Count, Link, Content-Disposition" always;

APIs you don't control

Some APIs send a useful header without exposing it. DummyJSON sends ETag and X-RateLimit-Remaining with no Access-Control-Expose-Headers, and PokeAPI sends ETag but exposes only X-PokeAPI-Hash and X-PokeAPI-Deploy-Date. Both read null in the browser.

proxy.cors.dev sends Access-Control-Expose-Headers with 13 names on every response: its own X-Cors-* headers plus Retry-After, ETag, Last-Modified, Link and the X-RateLimit-* family. Through the proxy, the DummyJSON and PokeAPI ETag became readable in all 3 browsers. It does not pass X-Total-Count or Content-Disposition through, so it is no fix for those, and rate-limit headers read through a shared proxy describe the proxy's quota at that API, not yours. The free tier needs no signup and handles GET and HEAD for text responses up to 1 MiB, with a 10-second deadline and 600 requests per minute per IP.

Read an ETag the API sends but does not expose
const target = 'https://dummyjson.com/products?limit=5';

const direct = await fetch(target);
const proxied = await fetch(`https://proxy.cors.dev/${target}`, { credentials: 'omit' });

if (!proxied.ok) console.log('proxy error:', proxied.headers.get('X-Cors-Error') ?? proxied.status);

console.table({
  direct: direct.headers.get('ETag'),
  proxied: proxied.headers.get('ETag'),
});

Good questions.

Why does headers.get() return null when DevTools shows the header?

DevTools shows every header that arrived, but a cross-origin script can read only the 7 safelisted headers plus the names in Access-Control-Expose-Headers. A hidden header returns null without any error.

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

Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified and Pragma, the 7 CORS-safelisted response headers. Every CORS header is described in the CORS headers list.

Can I use a wildcard in Access-Control-Expose-Headers?

Yes, for requests without credentials: * then exposes every header except Set-Cookie in Chromium 153, Firefox 155 and WebKit 26.6. With credentials: 'include' the * counts as a literal header name, so list each header explicitly.

Can fetch read Set-Cookie?

No. Set-Cookie stayed null in all 3 engines when listed, with *, and on the same origin, and getSetCookie() returned []. The browser still stores the cookie on a credentialed request; your script just can't see the header.

Does Access-Control-Expose-Headers go on the preflight or the actual response?

On the actual response. Sent only on the OPTIONS preflight, it exposed nothing in any of the 3 engines.

How do I read the filename from Content-Disposition on a cross-origin download?

Add Content-Disposition to Access-Control-Expose-Headers on the download response, then read response.headers.get('Content-Disposition'). proxy.cors.dev does not pass this header through, so set it on the origin server or your own backend.