CORS 报错怎么排查:从「预检过了还是失败」到一行响应头修好

控制台那句 CORS policy 说的不是「服务器拒绝了你」,而是「服务器少回了一个响应头」。按症状反查:为什么 Access-Control-Allow-Origin: * 配了还失败、withCredentials 为什么不让用通配符、预检 OPTIONS 返回 200 但实际请求仍被拦、Vary: Origin 缺了导致缓存串味。

约 8 分钟读完 · 更新于 2026-10-21 · 文中每条都能在下面的配套工具里动手验证

先定位浏览器在哪一步拦的:简单请求还是预检

CORS 是浏览器强制的同源策略,不是服务器端的鉴权机制——服务器根本没收到你被拦的那个请求。浏览器把请求分两类:满足「简单请求」条件的(GET/HEAD/POST 加少量固定头部、内容型限三种)直接发送并读响应,缺任何响应头就报错;带自定义头、JSON Content-Type、PUT/DELETE 这类会先发一个 OPTIONS 预检,只有预检通过才发真实请求。分清是哪一类,决定了你要查的是响应头还是预检链路的每一个字段。

报错文案 → 缺的那一个头 → 怎么补

控制台报错(截断)浏览器在等什么修法
No 'Access-Control-Allow-Origin' header is presentACAO:允许的源回显具体 origin,或在无凭证时用 *
The value of the ACAO header ... must not be the wildcard *不允许通配符 + 凭证回显具体 origin,并加 Allow-Credentials: true
Request header field authorization is not allowedACAH 未列出该头加 Access-Control-Allow-Headers,含 authorization
Method PUT is not allowed by Access-Control-Allow-MethodsACAM 未列出方法把该方法加进 ACAM 列表
Response to preflight request doesn't pass access control check预检响应缺头OPTIONS 分支单独补齐全部头并直接 204 返回
The CORS request was blocked because of a bad originorigin 不在白名单核对协议+主机+端口三段是否完全一致
预检 200 正常,真实请求被拦响应在网关/反代层丢了 CORS 头在最终对外的那一层补头,不要只在应用层加

为什么 Access-Control-Allow-Origin: * 配了还是失败

浏览器控制台里最常见的组合错误是:代码里设了 withCredentials: true,服务器回了 *,然后浏览器直接拒绝——规范明确写了凭证模式下响应里的 origin 头不得是通配符。第二个坑是 * 只在「不带凭证」时可用,而 Cookie、Authorization 头、客户端证书都属于凭证。所以真实项目里带认证的接口几乎都必须回显具体 origin,并把值从白名单里查出来。

✗ 通配符 + 凭证(浏览器直接拒绝)
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Credentials", "true");

// 前端 fetch(url, { credentials: "include" })
// 控制台:The value of the 'Access-Control-Allow-Origin' header
//         in the response must not be the wildcard '*'
//         when the request's credentials mode is 'include'.
✓ 回显具体 origin,并声明会因请求而变化
const ALLOW = new Set(["https://app.example.com", "https://admin.example.com"]);

app.use((req, res, next) => {
  const origin = req.headers.origin;

  if (origin && ALLOW.has(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);   // 回显,不写 *
    res.setHeader("Access-Control-Allow-Credentials", "true");
    res.setHeader("Vary", "Origin");                       // 关键:告诉缓存别串味
  }

  res.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type,Authorization,X-Requested-With");
  res.setHeader("Access-Control-Max-Age", "86400");       // 预检缓存一天

  if (req.method === "OPTIONS") return res.sendStatus(204); // 预检直接结束
  next();
});

预检 OPTIONS 返回 200,为什么真实请求还是被拦

这是最费时间的一类症状:Network 里 OPTIONS 那一行状态码是绿的,红色标在后面的真实请求上。区别在于,预检通过只说明「你允许我发这个方法、这些头」;真实请求能否读到响应,取决于最终响应上有没有 ACAO。两个高频原因:一是 OPTIONS 分支和真实分支走了不同的代码路径(常见于框架自动加了头、手写 OPTIONS 又提前 return,或者反代把 OPTIONS 单独路由了);二是 CORS 头加在应用层,但响应真正是网关 / CDN / 反向代理吐出来的,那一层没带 CORS 头,浏览器看到的就是裸响应。排查动作:拿失败请求的 URL 直接 curl,看响应头里有没有 ACAO——没有就是在网关层丢的。

curl 复现预检:与浏览器发的一模一样
curl -i -X OPTIONS "https://api.example.com/v1/orders" \
  -H "Origin: https://app.example.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type,authorization"

# 期望看到:
# HTTP/1.1 204
# Access-Control-Allow-Origin: https://app.example.com
# Access-Control-Allow-Methods: GET,POST,PUT,PATCH,DELETE,OPTIONS
# Access-Control-Allow-Headers: content-type,authorization
# Vary: Origin
Nginx 在最外层统一补头(含预检短路)
location /api/ {
    if ($request_method = OPTIONS) { return 204; }

    add_header Access-Control-Allow-Origin      $http_origin always;
    add_header Access-Control-Allow-Credentials "true"                always;
    add_header Access-Control-Allow-Methods     "GET,POST,PUT,PATCH,DELETE,OPTIONS" always;
    add_header Access-Control-Allow-Headers     "Content-Type,Authorization" always;
    add_header Vary                             "Origin"                always;

    proxy_pass http://127.0.0.1:3000;
}
# 注意:add_header 在 if / 嵌套 location 里会失效,always 不能省

Vary: Origin 缺了:从「偶发」变成「有时好有时坏」

当 ACAO 回显的是具体 origin 时,响应内容就随请求的 Origin 变化。如果没告诉缓存这件事,CDN、反向代理或浏览器缓存会把 A 站点拿到的响应头原样发给 B 站点——于是出现「同事那儿正常,我这儿一直失败」「刷新一下又好了」的随机现象。Vary: Origin 就是给缓存的这句说明。加了 max-age 之外的 CDN 还要注意:预检响应被缓存后,origin 白名单改动不会立刻生效,排查时用 curl 加 Cache-Control: no-cache 复测。

五步定位法:按这个顺序查,不要跳步

  1. Network 面板勾上 Preserve log,勾掉 Disable cache,重新发一次请求——确认红色那一行到底是 OPTIONS 还是真实请求。
  2. 在控制台执行 location.origin(以及 location.protocol、location.port),跟请求的 Origin 头逐字符比:协议、主机、端口三段任一不同就是跨域。
  3. 把失败请求 Copy as cURL,用命令行重放:能复现说明是服务端响应头问题,命令行不复现说明问题在浏览器侧(扩展、缓存、代理插件)。
  4. 看 OPTIONS 响应头:ACAM 有没有该方法、ACAH 有没有 authorization 和 content-type、ACAO 是否回显、是否带 Vary: Origin,四个缺一不可。
  5. 确认头加在哪一层:curl 直连应用服务与 curl 走网关地址各一次,两次响应头不同就说明 CORS 头被中间层吃掉了。

同源判定:三个最容易搞错的场景

  • 端口不同就是跨域。http://localhost:3000 调 http://localhost:8080 属于不同源,用 127.0.0.1 替 localhost 也一样不同源(主机名不同)。开发时最稳的做法是用 vite / webpack 的 devServer proxy 或 Nginx 反代,把接口路径变成同源相对路径,CORS 问题直接消失。
  • 协议不同就是跨域。页面走 https、接口走 http 会被浏览器直接拦掉(混合内容),不存在「加个 CORS 头就好」的说法。
  • CORS 与鉴权无关。它只决定浏览器是否把响应交给你看,不决定服务端是否执行。服务端该做的校验——权限、租户隔离、参数合法性——一个都不能因为「加了 CORS」而省掉。

常见问题

Access-Control-Allow-Origin 设成 * 了还是报错,为什么?

两种情况:一是请求带了凭证(withCredentials: include、Cookie、Authorization),规范禁止此时返回通配符,必须回显具体 origin;二是服务端在应用层加了头,但响应由网关或反代吐出,那一层没带,浏览器看到的是裸响应。用 cURL 转代码把请求重放一次,对比直连和走网关的响应头即可定位。

预检请求 OPTIONS 返回 200,真实请求还是被拦?

预检通过只代表「允许发这个方法和这些头」,真实响应能不能读,取决于最终响应上有没有 Access-Control-Allow-Origin。两个高频原因:OPTIONS 分支和真实分支走了不同代码路径(框架自动加头 + 手写 OPTIONS 提前 return,或反代把 OPTIONS 单独路由);CORS 头加在应用层但真正吐响应的是网关。curl 失败请求的 URL,看响应头里有没有这个字段,一眼就能定性。

Access-Control-Allow-Headers 要把所有头都列上吗?

不用。规则是「你允许什么,客户端就只能在预检里申请什么」。最省事且不会漏的做法是把客户端实际会带的自定义头列全(Content-Type、Authorization、X-Requested-With 这三个最常见),或者在服务端直接回显 Access-Control-Request-Headers——但回显等于放行任意头,安全上不如白名单。

CORS 报错和 403 报错怎么区分?

看请求有没有到达服务端。真正的 CORS 失败在浏览器控制台里出现且 Network 面板标红,服务端访问日志里通常找不到对应记录——因为请求被浏览器拦下了。403 是服务端明确返回的拒绝,日志里能看到请求,只是响应没带或带了不匹配的 ACAO 头。两者的排查起点完全不同,先确认这一点能省掉很多弯路。

📂

松开以自动格式化

支持 .json / .sql / .csv / .html / .css / .xml / .yaml 文件