
同一个 API,curl 能返回 JSON,前端却提示 CORS 错误,首先要检查浏览器看到的响应头,以及请求前是否发送了 OPTIONS 预检。curl 不执行浏览器的同源策略,命令行成功只能证明这次网络请求拿到了响应,不能证明网页脚本有权限读取它。
另一个容易误判的地方是错误响应。接口实际返回了 401、502 或重定向,但这些响应没有正确的跨域头,浏览器最终把问题显示成跨域失败。直接给所有来源开放访问,会掩盖原来的鉴权或上游故障,还可能增加风险。
本文适用于普通网页通过 Fetch 或 XMLHttpRequest 访问 HTTP API 的场景。示例域名需要替换为自己的测试域名;以下操作没有在读者的生产环境执行。对象存储授权、浏览器扩展和本地文件页面需要结合各自规则另行判断。
从 Network 面板区分预检与实际请求
浏览器的“源”由协议、主机和端口共同确定。https://app.example.com 与 https://api.example.com 不同源;同一主机换端口或从 HTTP 换成 HTTPS,也会产生跨源请求。
打开开发者工具的 Network 面板,保留日志后重试,记录请求 URL、Origin、状态码和响应头。按实际看到的请求判断:
| 现象 | 优先检查 |
|---|---|
| OPTIONS 返回 401、403、404 或跳到登录页 | 预检被鉴权、WAF、路由或重定向拦截 |
| OPTIONS 成功,但没有后续请求 | 允许来源、方法、请求头是否匹配 |
| 实际请求返回 200,脚本仍无法读取 | 实际响应的跨域头、凭据规则、重复响应头 |
| 实际请求返回 401、500 或 502 | 先定位原始业务或代理故障,同时核对错误响应头 |
控制台提示有价值,但不能代替完整请求链路。TLS 证书错误、DNS 失败和混合内容阻止,也会让 Fetch 报网络错误;这些问题需要分别处理。
JSON、Authorization 为什么容易触发 OPTIONS
只有符合“简单请求”条件的跨源请求才不需要预检。常见的 Content-Type: application/json、Authorization 或自定义请求头,会让浏览器先询问服务器是否允许相关方法和请求头。PUT、DELETE 等方法通常也需要预检。
下面用 curl 模拟一次预检,注意它只是帮助观察响应,不会替浏览器执行完整校验:
curl -sS --connect-timeout 5 --max-time 15 -D - -o /dev/null \
-X OPTIONS 'https://api.example.com/v1/profile' \
-H 'Origin: https://app.example.com' \
-H 'Access-Control-Request-Method: POST' \
-H 'Access-Control-Request-Headers: content-type,authorization'
该命令没有附带用户凭据。普通 CORS 预检本身不携带用户认证信息,服务器不应要求它先完成常规会话登录。生产环境应按路由和方法识别有效预检,并继续对后续实际请求执行身份验证与权限检查。放行预检不会替代实际接口授权。
状态码为 204 或其他成功状态,还不足以说明通过。返回头需要明确允许请求来源、POST 方法,以及将要使用的 content-type、authorization。只写 Allow: GET, POST 也不等于设置了 Access-Control-Allow-Methods。
预检通过后,实际响应也要有跨域头
对仅允许一个前端站点、需要凭据的 API,一组示例响应头如下:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
预检响应还需要与实际请求匹配的允许项,例如:
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization
这些都是示例,不能照抄到不需要对应权限的接口。允许来源必须是一个匹配的源,不带路径;不能用逗号把多个来源拼成一个 Access-Control-Allow-Origin。如果要支持多个站点,应在服务端用严格白名单判断后返回一个匹配来源,而不是把任意 Origin 原样反射回去。
跨域逻辑最好由应用或一个明确的网关层负责。如果应用已经返回 Access-Control-Allow-Origin,Nginx 又添加一次,重复头可能让浏览器拒绝响应。排查时应读取最终经过代理、CDN 后的结果。
Vary: Origin 用于告知缓存响应会随 Origin 改变。已有 Vary: Accept-Encoding 等值时应正确合并,不能为了跨域把原值覆盖掉;CDN 还需要支持并正确配置相应缓存行为,单加一个头不保证所有平台都按预期区分缓存。
带 Cookie 时,通配符为什么不行
前端需要跨源携带 Cookie 时,Fetch 通常要显式设置:
fetch('https://api.example.com/v1/profile', {
credentials: 'include'
});
服务端同时需要返回匹配的明确来源,以及 Access-Control-Allow-Credentials: true。凭据模式下不能用 Access-Control-Allow-Origin: *。Authorization 请求头也应在预检允许头中显式列出,不能依赖通配符来处理它。
Cookie 是否发送还取决于 Domain、Path、Secure、SameSite 以及浏览器第三方 Cookie 策略。跨源不一定跨站:两个子域可以跨源但仍属于同站。只有在确实需要跨站 Cookie 的部署中,才评估 SameSite=None; Secure,并同时落实 CSRF 防护,不能把修改 Cookie 属性当作通用跨域修复。
CORS 控制浏览器脚本读取响应的权限,无法阻止所有跨站请求发出。没有预检的请求也可能到达服务器,所以服务端授权、CSRF 防护和输入验证仍然不可缺少。
前端把模式改成 no-cors 同样无法解决需要读取 JSON 的业务:返回的 opaque 响应不能被脚本正常读取内容。关闭浏览器安全策略,也只能制造一个不代表真实用户环境的测试结果。
为什么只有报错时才显示跨域失败
应用正常响应带了跨域头,鉴权中间件或反向代理生成的错误响应没有带,就会出现“成功时正常、出错时跨域”的现象。要同时查看服务端日志中的原始状态,以及浏览器请求对应的时间和请求标识。
Nginx 的 add_header 默认只针对特定状态码添加响应头;使用 always 可以扩展到其他状态,但它不会自动完成来源白名单、预检处理或缓存配置。不要只给服务器随意加一行 add_header Access-Control-Allow-Origin * always; 就认为问题已解决。
还要检查配置继承:子级 location 定义了自己的 add_header 时,传统继承行为可能使上级头不再继承。较新版本还有额外继承控制能力,需按部署版本与实际生效配置判断。
涉及 Nginx 配置修改时,先备份相关站点文件,确认正在运行实例所用的路径,再执行 nginx -t。语法通过后,在维护安排下平滑重载;出现新故障时恢复备份并重新检查。修改不会修复本来就存在的上游超时或失效凭据。
CDN 与预检缓存会让修改看起来没生效
如果接口根据不同 Origin 返回不同响应,缓存策略必须能区分这些变化。对个性化、带凭据的响应,应按业务设置禁止共享缓存或严格隔离,不能只修跨域头而忽略用户数据的缓存边界。
预检还可能在浏览器自己的预检缓存中停留,Access-Control-Max-Age 控制相关缓存时间,并受浏览器上限影响。调整策略时可先在受控测试环境采用短时间值,并使用新的浏览器会话复查;不要把 Network 面板禁用普通缓存等同于清除了所有预检缓存。
如果 OPTIONS 到达边缘节点后被 WAF 拦截,需要只针对确认的 API 路径、来源和方法调整规则。不能为了测试全站关闭 WAF,也不能给所有任意来源授予凭据访问权限。
验收时同时检查允许和拒绝的场景
至少验证允许来源的预检、实际请求,以及未授权来源的拒绝结果。拒绝结果可以没有允许跨域头,但实际接口仍需依靠身份鉴别和权限校验保护数据,不能把来源校验当作鉴权。
再检查一次过期登录、无权限、应用错误和代理错误:浏览器能否在允许的来源下正确读到业务错误,服务端是否记录了原始状态。需要由前端读取自定义响应头时,还应按需设置 Access-Control-Expose-Headers;允许发送哪些请求头与允许读取哪些响应头是两件事。
最终应在真实浏览器中完成一次业务验证。curl 适合定位链路和头部,预检与实际请求都符合规则之后,才算解决了浏览器跨域访问的问题。
官方参考
资料核验日期:2026 年 10 月 21 日。




