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

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

**快速答案:** 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 只要没有自定义头，仍然是简单请求，浏览器会直接发送；但携带凭据会让响应检查变严，后文单独说明。

浏览器会先发：

```http
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”具体得多，常见的有：

```text
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 能看到原始响应：

```bash
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
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 头检查工具](/tools/cors-checker) 和 [HTTP 头解析工具](/tools/http-headers) 可以在不写脚本的情况下检查线上响应。

## 修复一：返回精确来源

携带凭据时，先校验来源白名单，再回显：

```js
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，真实请求永远不会发出。

```js
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`，服务端就必须允许它。

```js
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` 或分页头，需要额外声明：

```http
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: *` 和请求侧的通配符行为类似：只有不携带凭据时才暴露所有非安全列表头；携带凭据时，`*` 会被当成字面名称，什么也暴露不了。逐个列名更可移植，也更方便审计。

## 凭据与通配符的陷阱

下面这个组合是无效的：

```http
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 转换工具](/tools/curl-converter) 复现请求，检查状态码和响应体；用 [HTTP 请求工作台](/tools/http-request) 交互式测试方法、头和状态码。

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

## 参考资料

- [MDN - 跨源资源共享（CORS）](https://developer.mozilla.org/zh-CN/docs/Web/HTTP/CORS)
- [Fetch 标准 - CORS 协议](https://fetch.spec.whatwg.org/#http-cors-protocol)
- [OWASP - CORS 安全说明](https://owasp.org/www-community/attacks/CORS_OriginHeaderScrutiny)
- [MDN - 预检请求](https://developer.mozilla.org/zh-CN/docs/Glossary/Preflight_request)

---

- Canonical: https://www.jsonmind.com/zh-CN/guides/fix-cors-preflight-error
- Locale: zh-CN
- Published: 2026-11-09
- Updated: 2026-11-09
- Tools: cors-checker, http-headers, curl-converter, http-request