在前后端分离的项目中,后端经常会在响应头里带上一些自定义字段,比如X-Trace-Id用于链路追踪、X-Total-Count用于分页、X-Request-Id用于问题排查。抓包工具里明明能看到这些头部已经返回了,但在浏览器的JavaScript代码里通过res.headers.get('X-Trace-Id')拿到的却是null。这个问题几乎都出在一个地方:Access-Control-Expose-Headers暴露列表没有配置或者配置不完整。本文结合Nginx反向代理回源的场景,把暴露列表的原理和配置细节讲清楚。

一、为什么浏览器默认读不到自定义响应头
这是浏览器同源策略的一部分。对于跨域请求,浏览器默认只允许JavaScript访问六个 CORS 安全响应头:Cache-Control、Content-Language、Content-Type、Expires、Last-Modified、Pragma。这个集合之外的所有头部,无论服务端是否真实返回,都会被浏览器拦截在脚本层之外,抓包工具能看到,是因为抓包工具工作在网络层,不受同源策略约束。
想让脚本读到额外的头部,服务端必须在响应中显式声明Access-Control-Expose-Headers,把允许暴露的头名逐一列出来,多个头之间用英文逗号分隔。需要注意的是,这个限制只针对跨域请求,同域请求下脚本可以读取任意响应头。因此很多开发在本地联调(同域代理)时一切正常,部署到线上(跨域调用)后突然全部失效,原因就在这里。
还有一个容易被忽略的细节:如果头部名称中包含下划线,比如X_Request_Id,Nginx默认会丢弃这类请求头(受underscores_in_headers指令控制),虽然这个指令只作用于请求方向,但很多上游服务框架对下划线头的处理也不一致。实践中强烈建议自定义响应头统一使用连字符命名,即X-Request-Id这种形式,可以避免一整类莫名其妙的问题。
二、Nginx中配置暴露列表的正确姿势
在Nginx反向代理场景下,暴露列表既可以由上游应用自己设置,也可以在Nginx层统一管理。推荐在Nginx层配置,这样跨域策略集中在一处,改起来不用逐个服务发布。核心指令是add_header,基础写法如下:
server {
listen 80;
server_name api.ipipp.com;
location /api/ {
proxy_pass http://backend_upstream;
# 跨域基础配置
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
# 暴露列表:允许前端读取的自定义响应头
add_header Access-Control-Expose-Headers "X-Request-Id,X-Trace-Id,X-Total-Count" always;
}
}
always参数非常关键,一定要加上。默认情况下add_header只在响应状态码为200、201、204、206、301、302、303、304、307、308时生效,一旦上游返回了4xx或5xx错误,所有通过add_header添加的头部都会消失。而排查线上问题时恰恰最需要在错误响应中拿到X-Trace-Id去定位日志,不加always就会在这些场景下翻车。
另一个大坑是add_header的继承机制:子层级(location)中只要出现了一条add_header指令,父层级(server或http块)中定义的所有add_header都会失效,不会合并。比如你在server块里配了Access-Control-Allow-Origin,又在某个location里加了暴露列表的add_header,结果跨域配置整个失效,浏览器报CORS错误。解决办法是要么全部配置写在同一层级,要么在每个location里完整重复所有需要的头部。
三、回源场景下自定义头部被Nginx隐藏的排查
配置了暴露列表之后,如果前端依然读不到,且抓包发现响应里根本没有这个头部,那问题多半出在Nginx到上游的回源链路上。Nginx默认会透传大部分上游响应头,但对于某些头部有默认隐藏行为,比如Date、Server、X-Pad等。如果上游返回的自定义头也被意外剥离,通常是proxy_hide_header或第三方模块造成的,检查配置中是否有类似这样的语句:
# 这类配置会隐藏上游返回的指定响应头,导致浏览器收不到 proxy_hide_header X-Powered-By; # 如果确实需要隐藏某些头,同时又想暴露自定义头,两者可以并存 # 只要确保自定义头没有被 proxy_hide_header 命中即可
排查时推荐分两步验证:第一步绕过Nginx直接curl上游服务,确认上游确实返回了目标头部;第二步curl Nginx入口地址,对比响应头差异。如果直连上游有、经过Nginx没有,就聚焦检查proxy_hide_header、proxy_ignore_headers以及是否有Lua脚本或第三方模块在中间改写响应。反之如果Nginx出口有这个头,但前端还是读不到,那就回到暴露列表本身,检查头名拼写、大小写、逗号分隔格式以及是否遗漏了某个头部。
还有一种情况:使用了error_page做错误重定向或自定义错误页时,错误响应可能由Nginx本地生成而非上游返回,此时上游的头自然不会出现。可以结合proxy_intercept_errors的配置确认错误页的实际来源,必要时在错误页处理逻辑中手动补回头部。
四、一套完整的可复用配置模板
下面是一段整合了跨域预检、暴露列表、回源配置的完整模板,可以直接改造后使用:
upstream backend_upstream {
server 127.0.0.1:8080;
}
server {
listen 80;
server_name api.ipipp.com;
location /api/ {
# 处理预检请求,直接返回204,不走上游
if ($request_method = OPTIONS) {
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Methods "GET,POST,PUT,DELETE,OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type,Authorization,X-Custom-Token" always;
add_header Access-Control-Expose-Headers "X-Request-Id,X-Trace-Id,X-Total-Count" always;
add_header Access-Control-Max-Age 86400 always;
return 204;
}
proxy_pass http://backend_upstream;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
# 所有 add_header 写在 location 层,避免继承失效问题
add_header Access-Control-Allow-Origin $http_origin always;
add_header Access-Control-Allow-Credentials true always;
add_header Access-Control-Expose-Headers "X-Request-Id,X-Trace-Id,X-Total-Count" always;
}
}
模板中把所有add_header统一放在location层级,规避了继承陷阱;预检请求在Nginx层直接拦截返回,减轻上游压力;每个头部都带了always,保证错误响应下跨域头依然存在。前端验证时可以用这样的代码:
fetch('https://api.ipipp.com/api/list?page=1')
.then(res => {
// 配置了暴露列表后即可正常读取
console.log('链路ID:', res.headers.get('X-Trace-Id'));
console.log('总条数:', res.headers.get('X-Total-Count'));
return res.json();
})
.then(data => console.log(data));
总结一下核心要点:浏览器默认只暴露六个安全响应头,自定义头必须通过Access-Control-Expose-Headers声明;Nginx配置时务必加always参数并注意add_header的层级覆盖机制;回源链路上要确认proxy_hide_header没有误伤自定义头。掌握这几点,自定义响应头读取失败的问题基本都能快速定位并解决。
NginxExposeHeaders跨域配置修改时间:2026-09-07 17:09:13