调自己的 API 报 CORS 错:定位与修复

浏览器 fetch 报 CORS 但 curl 正常。分清真正的原因,在服务端修好,并验证。

你在前端 fetch('https://api.yourdomain.com/users'),控制台报:

Access to fetch at 'https://api.yourdomain.com/users' from origin
'https://app.yourdomain.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.

而 Postman 或 curl 调同一个 API 完全正常。这一点差异就是全部线索:CORS 是浏览器强制执行的,不是服务端强制执行的。 curl 和 Postman 从不发 Origin header,也不理会策略,所以畅通无阻;浏览器会发,所以它在拿到响应之后把结果拦下来。

最快的修法: 缺的那块几乎总是在服务端。加一个 CORS 中间件,让它返回 Access-Control-Allow-Origin,值是你前端确切的 origin(scheme + host + port,结尾不带斜杠),并且确保 OPTIONS preflight 上也带回同样的 header,而不是只有 GET / POST 上有。直接跳到最短修复路径。如果你只是想在本地继续开发、不动后端,那就用开发代理,别再用那个老的浏览器开关。

理解模型:对任何”非简单”的跨 origin 请求(带 JSON body、带自定义 header、或方法不是 GET/HEAD/POST 的),浏览器会先发一个 OPTIONS preflight。服务端必须用 Access-Control-Allow-Origin 等 header 回应这个 preflight。preflight 失败、或主响应缺这个 header,浏览器都会拒绝,你的 .then() 永远不会跑。

先确定你属于哪一类

打开 DevTools,进 Network,点那条失败的请求(用 Fetch/XHR 过滤,并留意单独的一行 OPTIONS),对照你的症状:

你看到的现象最可能的原因去看
响应里压根没有 Access-Control-Allow-Origin服务端没有 CORS 配置原因 1
OPTIONS 那行返回 404 / 405 / 500preflight 没被处理原因 2
header 有,但值跟你的 Origin 对不上Origin 字符串不匹配原因 3
报错说 “must not be the wildcard ’*‘“credentials + 通配符原因 4
报错说 “Request header field X is not allowed”Allow-Headers / Allow-Methods原因 5
curl 里有 header、浏览器里没有CDN / 代理把它吞了原因 6
页面弹出”允许访问你的本地网络”,或对 localhost / 局域网 IP 的请求被拦Local Network Access 权限(已取代老的 PNA preflight)原因 7

常见原因

按命中率从高到低排。

1. 服务端根本没发 Access-Control-Allow-Origin {#cause-1}

最高频。新项目没有 CORS 中间件,所以每个跨 origin 请求都失败。响应本身是好的(200、body 正确),只是缺了浏览器要求的那一个 header。

如何判断:Network → 点请求 → Response Headers,没有 Access-Control-Allow-Origin 这一行。

2. OPTIONS preflight 没被处理 {#cause-2}

任何带 JSON Content-Type、带自定义 header(AuthorizationX-API-Key)、或方法是 PUT/PATCH/DELETE 的请求都会触发 preflight。如果你的路由对 OPTIONS 返回 404/405(常见于只注册了 POST /users、没注册 OPTIONS /users),主请求就永远不会发出去。

如何判断:Network 里有单独一行 OPTIONS,状态码不是 2xx。浏览器期望 200 或 204。

3. Origin 字符串不匹配(scheme / port / 尾斜杠){#cause-3}

✅ Access-Control-Allow-Origin: https://app.example.com
❌ Access-Control-Allow-Origin: app.example.com          # 缺 scheme
❌ Access-Control-Allow-Origin: https://app.example.com/ # 多了结尾斜杠
❌ Access-Control-Allow-Origin: http://app.example.com   # http 不是 https
❌ Access-Control-Allow-Origin: https://app.example.com:443  # 写了默认端口

匹配是逐字节对照请求的 Origin header。差一个字符浏览器就当不匹配。www 和非 www 也算不同。

如何判断:把响应的 Access-Control-Allow-Origin 值和请求的 Origin header 对比,两者必须是完全相同的字符串。

4. credentials: include 配上通配符 * {#cause-4}

浏览器规则:带凭据的请求(cookie、Authorization、或客户端证书)要求一个具体的 origin,绝不能是 *,并且要带 Access-Control-Allow-Credentials: true。同样的限制也作用于 Access-Control-Allow-HeadersAccess-Control-Allow-Methods:带凭据时,通配符 * 会被当成字面意义上的字符串 *,而不是”全部”,所以你必须把真实的值一项项列出来。

如何判断:报错里含 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'

5. 缺 Allow-Headers / Allow-Methods {#cause-5}

前端发 Content-Type: application/json 或自定义 X-API-Key,两者都触发 preflight。如果服务端没在 Access-Control-Allow-Headers 里把它们回带回来,preflight 就会在真正调用之前先失败。

如何判断:报错里含 Request header field x-api-key is not allowed by Access-Control-Allow-Headers in preflight response.(消息里 header 名是小写的)。

6. CDN / 代理把 CORS header 吞了 {#cause-6}

Cloudflare、nginx 或 API Gateway 都可能丢弃或覆盖响应 header。origin server 发得是对的,浏览器却什么也没看到。缓存层还可能返回一个早于你 CORS 修复的旧响应——更糟的是,把某个 origin 的 Access-Control-Allow-Origin 缓存下来发给另一个 origin。如果你用了反射请求 Origin 的写法(见原因 4 / 下面的 Step 4),就必须同时发 Vary: Origin,让缓存按 origin 区分,而不是把它们混在一起。

如何判断:直接 curl origin server(绕开 CDN)看响应,和浏览器通过公网域名拿到的对比。

7. Local Network Access 拦住了对 localhost / 内网 IP 的调用 {#cause-7}

这一条在 2026 年变了,所以老攻略现在是错的。当一个公网页面(或任何 https:// 页面)去调一个私有地址上的后端——localhost127.0.0.1192.168.x.x / 10.x.x.x 局域网 IP,或 .local 主机——Chromium 现在会把这个请求拦在 Local Network Access(LNA)之后,弹出一个浏览器层面的权限提示

关键点:LNA 取代了更早的 Private Network Access(PNA)方案。PNA 已被搁置,所以老的修法——给服务端 preflight 响应加 Access-Control-Allow-Private-Network: true——在当前 Chrome 里已经不起任何作用了。LNA 不是靠服务端 header 来开启的,而是由用户通过提示授予(“<site> 想访问你本地网络上的设备”)。Chrome 在 141 版(2025 年 9 月 30 日发布)里上了这个提示,并在 142 版里更大范围地推开,所以一年前能用的 localhost 调用,可能在一次 Chrome 升级后突然失败。

如何判断:弹出一个关于本地网络的权限提示,或 DevTools 里那条请求被拦并带 Local Network Access 提示。再加 Access-Control-Allow-Private-Network 响应 header 也修不好了。

怎么修:开发时,接受那个权限提示(每个 origin 只需授予一次)。对于一个确实需要访问用户局域网设备的线上应用,给请求标上 targetAddressSpace,让提示干净地弹出,并用 HTTPS 提供页面。见 Chrome Local Network Access 说明。对于普通的前后端联调,最干净的绕法仍然是开发代理,它让一切保持同 origin。

最短修复路径 {#shortest-path-to-fix}

Step 1:服务端加 CORS 中间件

// Express (cors v2)
import cors from 'cors';
app.use(cors({
  origin: [
    'https://app.yourdomain.com',
    'http://localhost:3000', // dev
  ],
  credentials: true,
  methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
  maxAge: 86400, // preflight 缓存 24h
}));
# FastAPI / Starlette
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://app.yourdomain.com", "http://localhost:3000"],
    allow_credentials=True,           # 为 True 时,不要用 allow_origins=["*"]
    allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
    allow_headers=["Content-Type", "Authorization", "X-API-Key"],
)
// Hono
import { cors } from 'hono/cors';
app.use('/*', cors({
  origin: ['https://app.yourdomain.com', 'http://localhost:3000'],
  credentials: true,
  allowHeaders: ['Content-Type', 'Authorization', 'X-API-Key'],
  allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
  maxAge: 86400,
}));

中间件要注册在你的路由之前,也要在任何可能对 OPTIONS 返回 401 的鉴权中间件之前。

Step 2:处理 OPTIONS preflight

大多数 CORS 中间件会自动应答 OPTIONS。如果你要自己写,确保 preflight 返回 204、带上 header、且永远不走你的鉴权守卫:

app.options('/api/*', (req, res) => {
  res.set({
    'Access-Control-Allow-Origin': req.headers.origin,
    'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400', // 缓存 24h
  });
  res.sendStatus(204);
});

如果你的后端在 localhost 或局域网 IP 上、调用被拦,那是单独的 Local Network Access 权限,不是 CORS header 的问题——见原因 7。在当前 Chrome 里,老的 Access-Control-Allow-Private-Network 响应 header 已经帮不上忙。

Step 3:用 curl 验证

精确模拟浏览器发的那个 preflight,并读出响应 header:

curl -i -X OPTIONS https://api.yourdomain.com/users \
  -H "Origin: https://app.yourdomain.com" \
  -H "Access-Control-Request-Method: POST" \
  -H "Access-Control-Request-Headers: content-type"

# 正确的 preflight 返回 204(或 200),并带:
# Access-Control-Allow-Origin: https://app.yourdomain.com
# Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
# Access-Control-Allow-Headers: Content-Type

如果这三行 Access-Control-Allow-* 都在、且 origin 完全对得上,浏览器就会接受。

Step 4:正确处理 credentials

如果前端用 fetch(url, { credentials: 'include' }),你就不能用 origin: '*'——要反射一个具体的、被允许的 origin,并设 Access-Control-Allow-Credentials: true

// 必须列举;只反射被允许的 origin
app.use(cors({
  origin: (origin, cb) => {
    const allowed = ['https://app.yourdomain.com', 'http://localhost:3000'];
    cb(null, allowed.includes(origin)); // false -> 不发 header -> 浏览器拦截
  },
  credentials: true,
}));

Access-Control-Allow-Origin 永远只能放一个 origin 或 *,不能放空格分隔的列表,所以反射匹配到的那个 origin(如上)就是支持多个 origin 的标准做法。反射时还要同时发 Vary: Origin,免得共享缓存或 CDN 把一个 origin 的 allow-header 发给另一个。cors 包和 FastAPI 的 CORSMiddleware 会替你加上 Vary: Origin;如果你手写 header,记得自己加。

Step 5:CDN / 代理排查

# 直连 origin(绕开 CDN)
curl -i https://origin-server-ip/api/users \
  -H "Host: api.yourdomain.com" \
  -H "Origin: https://app.yourdomain.com"

# 走公网 CDN 域名
curl -i https://api.yourdomain.com/users \
  -H "Origin: https://app.yourdomain.com"

如果直连有 header、走 CDN 后没有,那就是边缘把它 strip 了。Cloudflare 上,检查 Transform Rules → Modify Response Header(并确认没有在发旧缓存:修好后做 purge)。nginx 上,proxy_pass 来的响应 header 默认会被转发,但一旦你在 proxy 块里设了任何 add_header Access-Control-*,就必须在 OPTIONS 分支里也加上,因为某个 location 里一旦声明了 add_header,它就不会再向下继承。

仅限开发的做法 {#dev-only-options}

不动后端、又想在本地继续开发,最干净的做法是用开发服务器代理,它让请求在浏览器看来变成同 origin(完全没有 CORS)。用 Vite 的话,在 vite.config.js 里加:

// vite.config.js —— 对 /api 的调用会被代理到后端,没有 CORS
export default {
  server: {
    proxy: {
      '/api': { target: 'http://localhost:8000', changeOrigin: true },
    },
  },
};

然后去 fetch /api/users,而不是后端的绝对 URL。Create React App(package.json 里的 proxy)和 Next.js(next.config.js 里的 rewrites())都有对应写法。

实在要做一次性快速测试,可以用关掉网页安全策略的方式启动 Chrome。截至 2026 年 6 月,这个开关只有在你同时传 --user-data-dir 时才生效,而且它会关掉真实的防护,所以绝不要在这个 profile 里正常上网:

# macOS —— 仅用于调试
open -na "Google Chrome" --args \
  --user-data-dir="/tmp/chrome_dev" \
  --disable-web-security

这只改你本地的浏览器;生产环境仍然必须返回正确的 CORS header。

怎么确认修好了

  1. Step 3 里的 curl -X OPTIONS 返回 204/200,且 Access-Control-Allow-Origin 对得上。
  2. 在 DevTools Network 里,OPTIONS 那行是 204,真正的请求是 200,且其响应上带 Access-Control-Allow-Origin header。
  3. 你的 fetch().then() 在页面里真的跑起来了(控制台没有红色错误),而且是在真实的 Chrome profile 里,不是那个 --disable-web-security 的。

预防建议

  • 本地开发时就把生产 origin 加进 CORS 允许列表,免得上线那天才发现这个缺口。
  • 用环境变量驱动列表:CORS_ORIGINS=https://app.example.com,http://localhost:3000,再按 , 切分。
  • 用一个统一的 CORS helper,别每个 route 自己写、写着写着就漂了。
  • 缓存 preflight(Access-Control-Max-Age: 86400),减少重复的 OPTIONS 流量。
  • 生产里绝不要把 origin: '*'credentials: true 配在一起——浏览器会拒绝,而且这是实打实的安全风险。
  • 反射请求 origin 时,永远带上 Vary: Origin,免得 CDN 把一个 origin 的 allow-header 发给另一个。
  • 任何 API Gateway / CDN 配置改完后,跑一次 curl 的 OPTIONS 自检,并 purge 边缘缓存。
  • 加一条 “CORS preflight 4xx” 报警,让误配出现在监控里,而不是用户的投诉里。

FAQ

为什么 curl/Postman 正常、浏览器却失败? curl 和 Postman 不发 Origin header、也不执行 CORS,只有浏览器执行。curl 通过只说明 API 能响应,对 CORS 一无所知。想用 curl 复现 CORS,加上 -H "Origin: https://app.yourdomain.com",再看响应里有没有 Access-Control-Allow-* header。

CORS 是服务端 bug 还是浏览器 bug? 都不是。CORS 是浏览器强制执行的安全模型。错误出现在浏览器里,但唯一长久有效的修法在服务端:为你前端的 origin 返回正确的 Access-Control-Allow-Origin(以及 preflight 的那些 header)。

我能不能直接设 Access-Control-Allow-Origin: * 只适用于公开的、无需鉴权的 API。一旦你的请求带 cookie 或 Authorization header(credentials: 'include'),浏览器就会拒绝 *,转而要求一个具体 origin 加 Access-Control-Allow-Credentials: true

我的 GET 能用、POST 被拦——为什么? 简单的 GET 常常跳过 preflight;带 JSON body 或自定义 header 的 POST 会触发 OPTIONS preflight。你要么缺 preflight 处理,要么 Access-Control-Allow-Methods 里缺这个方法,要么 Access-Control-Allow-Headers 里缺这个 header。去 Network 里看那条单独的 OPTIONS

上个月还能用,现在 Chrome 拦我的 localhost API。 很可能是 Local Network Access(原因 7)。一个公网 / https 页面去调 localhost 或局域网 IP 的后端,现在被一个浏览器权限提示挡住了,而不是缺 CORS header。LNA 取代了更早的 Private Network Access 方案,所以给服务端加 Access-Control-Allow-Private-Network: true 现在不起作用了。开发时接受提示,或把调用代理成同 origin。Chrome 在 141 版(2025 年 9 月)上了这个提示,142 版更大范围地推开。

这个错只在生产出现、本地不出现。 要么是你生产前端的 origin 不在允许列表里(原因 3),要么是 CDN/代理在 strip header 或返回过期的缓存响应(原因 6)。直连 origin 和走公网域名各 curl 一次,就能区分这两种情况。

相关阅读

外部参考:MDN —— CORS errorsMDN —— Access-Control-Allow-Origin

标签: #后端 #排查 #排查 #CORS