Exposing Response Headers with Access-Control-Expose-Headers

Failure symptom — the header is visibly present in DevTools, yet script reads null:

> await (await fetch('https://api.vertexlabs.io/v1/records')).headers.get('X-Total-Count')
< null

> [...r.headers.keys()]
< ['cache-control', 'content-length', 'content-type']

There is no red console message and no blocked request. The response arrived with a 200, the JSON body parsed fine, and the Network panel shows X-Total-Count: 4820 in the response headers. Only the Headers object your code touches is missing it.

Root Cause

A cross-origin response passes through a filter before JavaScript ever sees it. The WHATWG Fetch Standard defines a CORS-safelisted response header set — Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma — and every other header is stripped from the Headers object unless the server explicitly grants access to it by name in Access-Control-Expose-Headers. Nothing is blocked, nothing is logged; the value simply never reaches the JavaScript realm. This is a deliberate confidentiality boundary rather than a bug, and it is the one part of CORS that fails silently. The complete directive set that surrounds it is catalogued in Access-Control-* Header Directives, the parent reference for this fix.

The filter runs on the response the browser has already accepted, which is why DevTools shows the header while script does not:

The Fetch response header filter Two aligned columns separated by a filter band. The left column lists the headers the server actually sent on the wire; the right column shows what response.headers.get returns for each one. Safelisted headers pass through, while custom headers and forbidden names return null. Headers the server actually sent What response.headers.get() returns Content-Type: application/json Cache-Control: no-store ETag: "8f2a91" X-Total-Count: 4820 X-Request-Id: 7c1e-44b0 Set-Cookie: sid=... Fetch response header filter passes only the safelisted set plus the names given in Expose-Headers "application/json" safelisted "no-store" safelisted null not exposed null not exposed null not exposed null forbidden name Naming a header in Access-Control-Expose-Headers is the only way to move a custom header across this boundary

The seven safelisted names below never need a grant. Everything else does, and the third column is the reason most teams meet this problem at all — the headers developers actually want are the operational ones.

Response header Readable without a grant? Typical reason a client wants it
Content-Type Yes — safelisted Choosing a parser for the body
Content-Length Yes — safelisted Progress reporting on large payloads
Cache-Control Yes — safelisted Deciding whether to re-request
Last-Modified Yes — safelisted Displaying a freshness timestamp
ETag No — must be exposed Sending If-None-Match on the next call
X-Total-Count No — must be exposed Rendering pagination controls
X-Request-Id No — must be exposed Attaching a trace id to a bug report
Retry-After No — must be exposed Backing off correctly after a 429
Content-Range No — must be exposed Resumable and ranged downloads
Set-Cookie Never — forbidden name Not readable by script at any origin

Prerequisite State

Step-by-Step Fix

Step 1 — Grant the names from the application layer

Express is the shortest illustration. Build the exposed list once as a constant so the client contract lives in one place, and emit it on the actual response rather than inside an OPTIONS branch.

const express = require("express");
const app = express();

const ALLOWLIST = new Set(["https://reports.vertexlabs.io"]);
const EXPOSED = ["ETag", "X-Total-Count", "X-Page-Cursor", "X-Request-Id", "Retry-After"].join(", ");

app.use((req, res, next) => {
  const origin = req.get("Origin");
  if (origin && ALLOWLIST.has(origin)) {
    res.set("Access-Control-Allow-Origin", origin);
    res.set("Access-Control-Allow-Credentials", "true");
    res.set("Access-Control-Expose-Headers", EXPOSED);
  }
  res.set("Vary", "Origin");
  next();
});

app.get("/v1/records", (req, res) => {
  res.set("X-Total-Count", "4820");
  res.set("X-Page-Cursor", "eyJwIjoyfQ");
  res.json({ items: [] });
});

app.listen(8080);

If you already run the cors middleware, the same contract is one option — exposedHeaders accepts an array and serialises it for you:

const cors = require("cors");

app.use(cors({
  origin: ["https://reports.vertexlabs.io"],
  credentials: true,
  exposedHeaders: ["ETag", "X-Total-Count", "X-Page-Cursor", "X-Request-Id", "Retry-After"],
}));

The client side needs no change beyond reading the header, which now returns a string:

const res = await fetch("https://api.vertexlabs.io/v1/records?page=2", {
  credentials: "include",
});
const total = Number(res.headers.get("X-Total-Count"));
const cursor = res.headers.get("X-Page-Cursor");

Step 2 — Mirror the grant in the reverse proxy

Nginx only applies add_header to a small set of successful status codes unless you append always. Omitting it is the single most common way a working configuration turns into null on the exact responses where a trace id matters most — the 429s and 500s.

map $http_origin $cors_origin {
    default                          "";
    "https://reports.vertexlabs.io"  $http_origin;
}

server {
    listen 443 ssl;
    server_name api.vertexlabs.io;

    location /v1/ {
        add_header Access-Control-Allow-Origin      $cors_origin always;
        add_header Access-Control-Allow-Credentials "true" always;
        add_header Access-Control-Expose-Headers    "ETag, X-Total-Count, X-Page-Cursor, X-Request-Id, Retry-After" always;
        add_header Vary                             "Origin" always;

        proxy_pass http://records_upstream;
    }
}

Nginx skips an add_header whose value evaluates to an empty string, so an unrecognised origin gets no grant at all rather than an empty one. Pairing the reflected origin with Vary is not optional here; the caching consequences are worked through in How to Fix Missing Vary: Origin Header Breaking CORS Cache Segmentation.

Step 3 — Enumerate names instead of a wildcard on credentialed endpoints

Access-Control-Expose-Headers: * is real and useful, but its meaning flips based on the request’s credentials mode. In a credentialed request the Fetch Standard stops treating the asterisk as a wildcard in CORS response header lists and compares it as an ordinary header name, so a response carrying only * exposes nothing.

Wildcard versus explicit names in Access-Control-Expose-Headers A two-by-two matrix. The rows are the header value sent by the server; the columns are the credentials mode of the fetch call. The asterisk works as a wildcard only when credentials are omitted or same-origin, while an explicit list works in both modes. Value the server sends on the real response credentials: 'omit' or 'same-origin' credentials: 'include' cookies or Authorization Expose-Headers value is * true wildcard: every non-forbidden name readable literal match: only a header genuinely called * is exposed Expose-Headers ETag, X-Total-Count both names readable, all other headers still filtered both names readable and credentials are honoured Only the bottom row is safe in both modes, which is why an explicit list is the portable choice

An explicit list is also the safer default for a second reason: it documents the client contract. Anyone reading the configuration can see which operational headers external callers depend on, and deleting one becomes a deliberate breaking change rather than an accident.

Step 4 — Keep the grant on error responses

Trace ids matter most when something failed, so the grant must survive the error path. In Express that means the header is set in middleware that runs before the router, as in Step 1, not inside individual handlers. In Nginx it means always on every add_header. If an upstream application framework writes the error response itself — a 502 produced by the proxy, for instance — the proxy is the only layer that can attach the grant, which is why Steps 1 and 2 are both required rather than alternatives.

Where the Grant Gets Lost

When the header is correct in the application and still absent in the browser, an intermediary removed it. Each hop is capable of dropping the directive for a different reason, and the debugging order follows the response backwards from the browser.

Where an Expose-Headers grant is lost between the application and the browser Four stages connected left to right: application, reverse proxy, edge cache and browser. Under each stage a note gives the specific way that hop can remove or invalidate the Access-Control-Expose-Headers grant before script reads the response. Every hop can drop the grant before the browser applies its filter Application Express or Rails Reverse proxy Nginx Edge cache CDN Browser Fetch filter grant emitted on every real response add_header without always drops 4xx/5xx cached copy has no grant and no Vary key headers.get() returns null, with no error Repair order: set the grant in the application, add always at the proxy, then confirm the edge keys its cache on Origin before you cache anything

Isolating the hop is a matter of asking each layer directly rather than through the browser, a technique developed in Troubleshooting CORS at the Proxy Layer. Send the same request to the application port, then to the proxy, then through the public hostname, and compare the three header sets.

Verification

Read the raw response headers from the command line first, because curl applies no filter at all and therefore shows the ground truth:

curl -sS -D - -o /dev/null https://api.vertexlabs.io/v1/records \
  -H 'Origin: https://reports.vertexlabs.io' \
  | grep -iE '^(access-control-|etag|x-total-count|x-request-id|vary):'

Expected output includes access-control-expose-headers: ETag, X-Total-Count, X-Page-Cursor, X-Request-Id, Retry-After alongside the headers it names. Then force an error path and confirm the grant is still there:

curl -sS -D - -o /dev/null https://api.vertexlabs.io/v1/records?page=-1 \
  -H 'Origin: https://reports.vertexlabs.io' \
  | grep -i '^access-control-expose-headers:'

Work through the checklist in the browser afterwards:

If the OPTIONS row is where you are looking, the walkthrough in Reading a Preflight OPTIONS Request in DevTools explains which of the two rows carries which guarantee.

Security Boundary Note

Exposing a header is a deliberate widening of what a foreign origin may read, so treat the list as an interface rather than a convenience. Two categories deserve care. First, internal diagnostics: X-Upstream-Host, X-Backend-Server, X-Debug-Query-Time and similar values leak topology and timing signals to any origin you have allowlisted, and to anything running inside those pages. Second, anything derived from a secret — a signed URL fragment, a partial token, a rate-limit key — becomes readable by script and therefore by any injected code on the allowed origin.

The safe posture is a short, reviewed list of headers the client genuinely consumes, never a blanket * copied from a tutorial. Because exposure only applies to origins that already passed origin validation, the strength of the grant is bounded by the strength of the allowlist in front of it — the reasoning developed in Credential Sharing & Security Boundaries in CORS.

Common Mistakes

Mistake Technical impact Fix
Setting Access-Control-Expose-Headers only on the OPTIONS response Browsers ignore the directive on a preflight, so script still reads null after a passing preflight Emit it on every actual response; the preflight branch does not need it
Using * on a credentialed endpoint The asterisk is matched as a literal header name, exposing nothing at all Enumerate the header names explicitly
Nginx add_header without always The grant disappears exactly on 4xx and 5xx, hiding the trace id when it is most needed Append always to each add_header directive
Listing Set-Cookie in the exposed list No effect — it is a forbidden response header name and is filtered before CORS logic runs Return the value the client needs in the body or a separate custom header

FAQ

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

On the actual response. The preflight negotiates what the browser may send; Access-Control-Expose-Headers governs what script may read from the response that comes back. A browser ignores the directive entirely on a 204 preflight, so setting it only inside an OPTIONS branch produces exactly the null you were trying to fix. Set it on every real response, including error responses.

Why does Access-Control-Expose-Headers with a wildcard not work for my logged-in dashboard?

Because the request is credentialed. When a fetch runs with credentials: 'include', the Fetch Standard stops treating the asterisk as a wildcard in CORS response header lists and matches it as a literal header name. A response carrying only the asterisk therefore exposes nothing at all to a credentialed caller. Enumerate the header names explicitly whenever cookies or an Authorization header are attached.

Can I expose Set-Cookie so my client can read the session value?

No. Set-Cookie and Set-Cookie2 are forbidden response header names in the Fetch Standard, and the browser filters them out of the Headers object before any CORS logic runs, on same-origin responses too. Naming them in Access-Control-Expose-Headers has no effect. If the client genuinely needs a token value, return it in the JSON body or in a separate custom header that you expose deliberately.