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 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
- The cross-origin request already succeeds:
Access-Control-Allow-Originmatches the calling origin and the response body is readable. - You know precisely which header names the client reads. Guessing produces an over-broad grant that is hard to retire later.
- You have edit access to whichever layer terminates the response — the application, the reverse proxy, or both.
- If the calls are credentialed,
Access-Control-Allow-Credentials: trueis already being emitted; the interaction with the wildcard covered in Step 3 depends on it.
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.
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.
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.