JsonMind
API 与接口8 分钟阅读更新于 2026年11月9日

CORS 预检请求失败怎么修:Access-Control-Allow-Origin 完整排查

从浏览器、curl 和服务端三个层面排查 CORS 预检失败,讲清允许来源、允许方法、允许头和 credentials 的正确组合。

JM作者:JsonMind Engineering最后复核: 2026年11月9日依据权威规范与引用来源复核。
快速答案

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 排查清单

  1. 判断请求是简单请求还是需要预检。
  2. 用 curl 复现 OPTIONS 请求。
  3. 确认状态码是 2xx。
  4. 确认 Access-Control-Allow-Origin 是精确来源。
  5. 确认方法已列出。
  6. 确认每个自定义头已列出。
  7. 确认凭据配置正确。
  8. 确认存在 Vary: Origin。
  9. 检查 CDN 和代理行为。
  10. 确认响应体和内容类型符合预期。
  11. 确认服务端日志在 OPTIONS 和真实请求里都能看到 Origin 头。
  12. 确认白名单条目没有末尾斜杠,非默认端口已包含在来源里。
  13. 确认预检缓存没有掩盖刚做的修改;新来源或新的请求头集合都会强制重新预检。

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、脚本或攻击者直接请求。服务端仍然必须做身份认证和权限校验。

参考资料

把指南用起来

浏览全部工具

打开浏览器工具。输入内容保留在浏览器中,不会上传到业务服务器。

相关指南

全部指南