CORS 预检失败说明浏览器先发了 OPTIONS 请求,但服务端没有返回真实请求所需的条件。服务端必须返回精确的来源、允许的方法和每一个自定义请求头,并且让 OPTIONS 请求返回 2xx,浏览器才会继续发送真正的请求。
- CORS 是浏览器执行的策略,curl 和 Postman 不会执行。
- 非简单请求会先触发 OPTIONS 预检。
- 携带凭据时,Access-Control-Allow-Origin 必须是精确来源,不能是 *。
- 通配符来源和 Access-Control-Allow-Credentials: true 不能同时使用。
- 用 curl 复现预检,能看到浏览器实际收到的响应头。
CORS 报错让人困惑,是因为网络面板里请求明明返回了 200,控制台却说失败。实际上服务端确实返回了数据,只是浏览器拒绝把响应交给页面脚本。
这篇文章把预检请求和真实请求分开看,给出可复现的排查步骤,以及最常见的几种头部组合错误。
什么情况会触发预检
只有“简单请求”不会预检。简单请求需要同时满足:方法是 GET、HEAD 或 POST;请求头都在安全列表内;Content-Type 只取 application/x-www-form-urlencoded、multipart/form-data 或 text/plain。
以下情况会触发 OPTIONS 预检:
- 使用
PUT、PATCH或DELETE。 - 使用
application/json等安全列表之外的 Content-Type。 - 携带
Authorization、X-Request-Id等自定义头。 Range头请求多个区间。
安全列表内的请求头只有 Accept、Accept-Language、Content-Language、上述三种 Content-Type 和单区间 Range,其他头即使值为空也会让请求变成非简单请求。credentials: "include" 本身不会触发预检:带 Cookie 的 GET 只要没有自定义头,仍然是简单请求,浏览器会直接发送;但携带凭据会让响应检查变严,后文单独说明。
浏览器会先发:
OPTIONS /api/orders HTTP/1.1
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
服务端必须返回 2xx,并带上允许的来源、方法和头,浏览器才会发真实请求。
Access-Control-Max-Age 决定浏览器缓存预检结果多久。浏览器会给自己设上限:Chromium 历史上最多缓存两小时,Firefox 最多 24 小时,所以服务端返回 86400 也只能拿到浏览器允许的最大值。缓存键包含来源、URL、方法和请求头集合,给请求新增一个头,这个组合就会重新预检。这也是后端改了配置却“看起来没生效”的常见原因。
读懂报错
现代浏览器的提示比“CORS error”具体得多,常见的有:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
Response to preflight request doesn't pass access control check.
The value of the 'Access-Control-Allow-Origin' header must not be '*' when the request's credentials mode is 'include'.
Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.
每一条都指向不同的缺失项。不要条件反射地把来源改成 *,那会破坏携带凭据的请求,也可能把数据暴露给不该访问的来源。
JavaScript 拿不到失败响应的状态码。fetch 会以 TypeError 结束:Chromium 显示 Failed to fetch,Firefox 显示 NetworkError when attempting to fetch resource,Safari 显示 Load failed,DevTools 里的状态可能是 0。真正原因只在控制台的 CORS 信息里。因此用 try/catch 包住 fetch 无法区分 CORS、DNS 失败、离线和混合内容,必须结合控制台和网络面板判断。
Firefox 的措辞通常更直接:Cross-Origin Request Blocked: The Same Origin Policy disallows reading the remote resource at <url>. (Reason: CORS header 'Access-Control-Allow-Origin' missing). 其中 Reason 会点名缺失的头,比 Chrome 的长解释更容易直接定位。
用 curl 复现预检
浏览器网络面板有用,但 curl 能看到原始响应:
curl -i -X OPTIONS https://api.example.com/orders \
-H "Origin: https://app.example.com" \
-H "Access-Control-Request-Method: POST" \
-H "Access-Control-Request-Headers: authorization, content-type"
正确的响应大致是:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
Vary: Origin
如果返回 401、403 或 404,说明预检被错误的层处理了;如果缺少头部,说明 CORS 中间件没有作用到 OPTIONS 路由;如果携带凭据却返回 *,浏览器会拒绝。
还有两个状态码值得单独说明。返回 405 Method Not Allowed,说明路由存在但不允许 OPTIONS,有些框架只有全局安装 CORS 中间件时才会自动响应 OPTIONS。返回 301 或 302,说明预检被重定向;浏览器历史上会拒绝预检过程中的重定向,应该直接请求规范 URL(通常是 HTTPS 主机、不带末尾斜杠),而不是依赖跳转。
两种 curl 变体可以帮助分层。加 --resolve api.example.com:443:<源站IP> 把请求直接打到源站,再和 CDN 的响应对比;发送 Origin: null 可以看到沙箱 iframe 或 file:// 页面收到的结果,如果服务端回显 null,等于对所有不透明来源开放,这几乎不会是本意。
CORS 头检查工具 和 HTTP 头解析工具 可以在不写脚本的情况下检查线上响应。
修复一:返回精确来源
携带凭据时,先校验来源白名单,再回显:
const allowedOrigins = new Set([
"https://app.example.com",
"https://admin.example.com"
]);
app.use((req, res, next) => {
const origin = req.headers.origin;
if (origin && allowedOrigins.has(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
res.setHeader("Access-Control-Allow-Credentials", "true");
res.setHeader("Vary", "Origin");
}
next();
});
Vary: Origin 对缓存很重要。没有它,CDN 可能把一个来源的响应缓存后发给另一个来源。
Origin 是序列化后的来源:协议、主机和端口,默认端口会省略,不带路径,也不带末尾斜杠。白名单里写成 https://app.example.com/ 永远匹配不上,因为浏览器发送的是 https://app.example.com,这个末尾斜杠是最常见的配置错误之一。匹配前统一协议和主机的大小写,并且必须整体相等,不能用前缀或后缀判断:https://app.example.com.evil.test 前缀完全正确,但属于别人。
不要为了让本地文件或沙箱 iframe 能用,把 "null" 加进白名单。Origin: null 来自沙箱 iframe、data: 和 file: 文档以及部分重定向链,允许它等于允许所有无法识别的来源。本地开发应该把前端跑在 localhost 端口上,放行那个精确来源。
修复二:让预检先于鉴权
预检请求不会携带 Cookie 或 Authorization。如果鉴权中间件排在前面,浏览器会收到 401,真实请求永远不会发出。
app.options("*", cors(corsOptions));
app.use(authenticate);
app.use(cors(corsOptions));
顺序取决于框架和代理,但原则一样:让预检在不要求凭据的情况下到达 CORS 处理。
顺序问题不只在鉴权。CSRF 中间件、按 IP 的限流、请求体大小限制都可能拒绝一个没有 Cookie、没有正文的 OPTIONS 请求。要求 Content-Type: application/json 的正文校验会对每个预检返回 415,因为浏览器根本不会发送这些内容。应该显式豁免预检请求,并在框架层面确认豁免生效,而不是假设 CORS 包一定先运行。
修复三:允许实际发送的头
浏览器会不区分大小写地比较请求头和 Access-Control-Allow-Headers。客户端发送 Authorization,服务端就必须允许它。
res.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Request-Id");
不要把请求里的任意头原样回显。白名单更安全,也更容易审计。
Access-Control-Allow-Headers: * 不是万能通配。这个通配符只在请求不携带凭据时生效,而且即使如此,Authorization 也被明确排除在外,必须逐字列出。使用 Bearer Token 的接口因此永远要显式写 Authorization。请求头比较不区分大小写,但浏览器在 Access-Control-Request-Headers 里发送的是小写名称;服务端如果拿原始字符串和驼峰白名单比较,即使符合 HTTP 规范也会失败,比较前应统一大小写。
修复四:暴露响应头
CORS 也控制 JavaScript 能读取哪些响应头。默认只暴露一小部分。如果客户端需要 X-Request-Id、ETag 或分页头,需要额外声明:
Access-Control-Expose-Headers: X-Request-Id, ETag
这和 Access-Control-Allow-Headers 是两回事:前者控制“能读什么”,后者控制“能发什么”。
安全列表内的响应头同样很短:Cache-Control、Content-Language、Content-Length、Content-Type、Expires、Last-Modified 和 Pragma。其他头都需要显式 Access-Control-Expose-Headers,包括常用的 X-Request-Id、ETag、Location 和 Content-Disposition。Set-Cookie 无论加不加这个头,都不会暴露给 JavaScript。
Access-Control-Expose-Headers: * 和请求侧的通配符行为类似:只有不携带凭据时才暴露所有非安全列表头;携带凭据时,* 会被当成字面名称,什么也暴露不了。逐个列名更可移植,也更方便审计。
凭据与通配符的陷阱
下面这个组合是无效的:
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
浏览器会拒绝,因为通配符意味着任何站点都能发起携带凭据的请求。应该返回精确来源。如果不需要 Cookie 或客户端证书,就不要设置 credentials,公开数据才可以使用通配符。
凭据还依赖 Cookie 属性,不只是 CORS 头。跨站请求需要 SameSite=None; Secure;SameSite=Lax 的 Cookie 根本不会随跨域 fetch 发送。即使 CORS 头完全正确,浏览器拦截第三方 Cookie 时也会丢弃它,服务端看到的仍是匿名请求。重新排查头部之前,先看 DevTools 的 Cookie 列。
如果前后端都由自己控制,同源反向代理或 BFF 可以把 CORS 从浏览器路径上彻底移除,并把 Token 留在服务端。代价是多一跳和一套要运维的服务;收益是没有预检、不用决定通配符,也不依赖第三方 Cookie。
代理和 CDN 层
常见架构有三层:浏览器、CDN、源站。任何一层都可能添加、删除或重复 CORS 头。
- 在边缘节点检查响应头,不要只看源站。
- 不要随意重写 CORS 头。
- 确认 CDN 转发了
Origin请求头。 - 确认 CDN 不会把一个来源的响应缓存后发给另一个来源。
- 确认 CDN 能处理或转发 OPTIONS 请求。
头部重复会产生另一条明确的报错:The 'Access-Control-Allow-Origin' header contains multiple values 'https://a.example.com, https://a.example.com', but only one is allowed. 通常是源站和边缘节点各加了一次。修复方法是删掉其中一层的规则,而不是在边缘关掉 CORS。
响应随来源变化时,Vary: Origin 不是可选项。CDN 把 Access-Control-Allow-Origin: https://app.example.com 缓存后发给另一个来源,本身不会造成数据泄露,因为浏览器仍会拿它和自己的来源比较,但它会破坏合法来源,并让排查产生误判。有些 CDN 会删除或重写 Vary,要在浏览器实际收到的响应上确认,而不是只看源站输出。
什么时候不是 CORS 问题
有时控制台提示 CORS,实际原因是网络失败、混合内容拦截,或者请求被重定向到了 HTML 登录页。如果响应是 HTML,浏览器同样会报 CORS,因为预期的头都不存在。用 cURL 转换工具 复现请求,检查状态码和响应体;用 HTTP 请求工作台 交互式测试方法、头和状态码。
mode: "no-cors" 不是解决方案。它通过把响应变成 opaque 来消除控制台报错,但 JavaScript 依然读不到状态码和正文,数据等于不存在。它只适合 beacon 式的单向日志上报,而且服务端仍可能拒绝请求。
混合内容是另一种长得像 CORS 的故障:HTTPS 页面根本无法请求 http://api.example.com。浏览器在 CORS 之前就拦截了请求,控制台提到的是混合内容,而不是缺少 CORS 头。证书过期和 DNS 失败也会让 JavaScript 收到同样的 TypeError,所以先确认请求出现在网络面板里,再读 CORS 信息。
CORS 排查清单
- 判断请求是简单请求还是需要预检。
- 用 curl 复现 OPTIONS 请求。
- 确认状态码是 2xx。
- 确认
Access-Control-Allow-Origin是精确来源。 - 确认方法已列出。
- 确认每个自定义头已列出。
- 确认凭据配置正确。
- 确认存在
Vary: Origin。 - 检查 CDN 和代理行为。
- 确认响应体和内容类型符合预期。
- 确认服务端日志在 OPTIONS 和真实请求里都能看到
Origin头。 - 确认白名单条目没有末尾斜杠,非默认端口已包含在来源里。
- 确认预检缓存没有掩盖刚做的修改;新来源或新的请求头集合都会强制重新预检。
CORS 是浏览器和服务端之间的契约,不是服务端的安全边界。预检和真实响应一致,报错就会消失;不一致时,浏览器其实已经告诉你缺了哪一块。
常见问题
为什么 Postman 能请求成功,浏览器却报 CORS?
Postman 不执行浏览器的同源策略。浏览器会检查响应头,缺少正确的 CORS 头时,即使服务端返回了数据,JavaScript 也读不到。问题在服务端的 CORS 配置,而不是请求本身。
预检响应必须包含哪些头?
至少要有 Access-Control-Allow-Origin。预检还需要 Access-Control-Allow-Methods 和 Access-Control-Allow-Headers,分别对应实际请求的方法和自定义头。如果携带凭据,还要有 Access-Control-Allow-Credentials: true 和精确来源。
Access-Control-Allow-Origin: * 可以配合 Cookie 吗?
不可以。请求携带凭据时,浏览器要求返回精确来源,通配符会被拒绝。应该先校验请求来源是否在白名单中,再回显该来源。
为什么 OPTIONS 请求返回 401 或 403?
通常是鉴权中间件排在 CORS 处理之前。浏览器不会在预检请求中携带凭据,所以服务端直接拒绝了。应该让 OPTIONS 请求先经过 CORS 处理,或者在鉴权之前放行预检。
CORS 能保护服务端不被未授权访问吗?
不能。CORS 是浏览器侧的读取限制,不是服务端鉴权。它阻止不了 curl、脚本或攻击者直接请求。服务端仍然必须做身份认证和权限校验。