CORS跨域配置:为什么不能用*,以及正确的Access-Control-Allow-Origin设置
如果你在浏览器控制台里见过下面这行红色报错那你大概率正处于前后端联调的“至暗时刻”Access to XMLHttpRequest at https://api.example.com/data from origin http://localhost:3000 has been blocked by CORS policy: No Access-Control-Allow-Origin header is present on the requested resource.这行报错的主角就是本文要聊的Access-Control-Allow-Origin响应头。很多后端同学在第一次处理跨域问题时最自然的反应就是加一个*毕竟官方文档里都写着“这是一个通配符表示允许所有来源访问”省事又省心。但我要说的是如果你只是图省事全部写死成*轻则线上功能“时灵时不灵”重则把用户数据直接暴露给任何恶意网站。这篇文章会用实际场景拆解为什么不能无脑用*以及正确配置的完整思路和可直接参考的代码方案。适合刚接触前后端分离的初学者也适合已经在线上踩过 CORS 坑、想系统理清机制的同学。1. 一个把*当万能钥匙的下午CORS 报错现场与根因1.1 从一行红色报错说起我曾经排查过这样一个线上事故。前端同事反馈说生产环境部分用户登录后个人信息接口的请求全部失败控制台报的就是开头那句 CORS 错误。大家第一反应都是“跨域问题”于是后端同事直接在网关层加了一行add_header Access-Control-Allow-Origin *;刷新页面第一次确实好了。但过了几分钟新的诡异现象出现了同一台电脑上用户 A 登录后数据正常用户 B 登录后数据却读不到甚至同一个用户关掉浏览器重开第一次请求正常第二次马上报错。最后查出来的根因让人哭笑不得Nginx 上游服务返回的响应里带了Set-Cookie而Access-Control-Allow-Origin被设置成了*等于告诉浏览器“我允许任何来源读取而且响应里没有凭证”但前端代码里明明开了withCredentials浏览器直接判定请求失败。更麻烦的是因为没加Vary: OriginCDN 把带 CORS 头的响应缓存下来又发给了其他来源的请求造成“串号”现象。这个事故里有三个典型错误前端需要携带 Cookie 时后端却回了Access-Control-Allow-Origin: *这在 CORS 规范里明令禁止。后端只加了 CORS 头但没有处理浏览器的预检请求OPTIONS导致部分 POST/PUT 请求被广泛拦截。响应头没有设置Vary: Origin缓存系统把同一份响应无差别分发给了不同来源。先说结论CORS 头不是“加了就行”而是要根据请求来源、请求方法、是否携带凭证、缓存策略等多种因素组合配置。只有把这些因素都考虑清楚才能获得一个稳定且安全的跨域方案。1.2 CORS 保护的不是服务器而是浏览器用户很多人误解 CORS 是“服务器安全机制”实际上它保护的是浏览器里的用户。服务器本身完全有办法拒绝请求比如通过 IP 黑白名单、API 密钥、限流等。CORS 规范做的事情很特殊它把“能不能跨域读取响应”的权力交给了浏览器由浏览器在发出请求之前和收到响应之后执行一套校验规则。举个例子恶意网站evil.example想在用户浏览器里偷偷向https://bank.example.com/api/balance发请求。如果服务器什么都不设置浏览器收到响应后会检查响应头里有没有Access-Control-Allow-Origin没有就拦截恶意网站拿不到响应内容。这个动作的受益者是用户因为浏览器替用户挡下了“读走敏感数据”的攻击路径。理解了这一点你就知道为什么*很危险了*意味着浏览器看到任何来源都合法恶意网站也能正常读取接口响应。如果这个接口返回的是用户手机号、订单、付款信息那等于在同一台浏览器里任何网站都能把你的数据读走。所以安全的配置思路一定是“白名单”而不是“全部放行”。2. 同源策略与握手流程为什么要“多管闲事”2.1 同源怎么判协议、域名、端口一个都不能少在配置 CORS 之前先搞清楚“什么是同源”。同源的定义非常严格必须同时满足三个条件条件示例 A示例 B是否同源协议httpshttp否域名www.example.comapi.example.com否端口4438080否有个常见的坑http://localhost:3000和http://127.0.0.1:3000虽然指向同一台机器的同一个服务但在浏览器看来它们是不同源因为域名文本不同。开发的时候你可能会遇到“本地明明通了上测试环境就挂”往往就是这个原因。同源策略是浏览器的底层安全模型。它规定一个网页里的脚本只能读取“与自己同源”的接口响应。想要跨域读取就必须经服务器明确授权授权的方式就是返回正确的 CORS 响应头。我们经常遇到的前后端分离场景本质就是让http://localhost:3000的前端页面请求https://api.xxx.com的后端接口。两类服务域名不同天然处于跨域状态这时候不配 CORS 根本没法联调。2.2 简单请求与预检请求两条完全不同的路很多人以为 CORS 就是“后端加个头”实际上浏览器在跨域请求时可能走两条完全不同的路取决于请求类型。简单请求必须同时满足以下条件方法只能是GET、HEAD、POST请求头只能是Accept、Accept-Language、Content-Language、Content-Type等安全头Content-Type只能是application/x-www-form-urlencoded、multipart/form-data、text/plain简单请求的行为是浏览器直接发出真实请求然后检查响应头。如果没有Access-Control-Allow-Origin或者校验不通过就拦截响应并报 CORS 错误。预检请求则要复杂得多。只要请求方法不是简单方法比如PUT、DELETE、PATCH或者带了非安全请求头比如常见的Authorization、X-Requested-With、X-Custom-Header或者Content-Type是application/json浏览器就会先发一个OPTIONS请求询问服务器“我打算跨域发一个 PUT 请求带这些请求头你允许吗”服务器需要对这个预检请求返回Access-Control-Allow-Methods和Access-Control-Allow-Headers浏览器确认“允许”之后才继续发真正的请求。这个流程是 CORS 出问题的高发区。很多后端只配置了Access-Control-Allow-Origin没有处理OPTIONS请求导致所有Content-Type: application/json的 POST 请求都发不出去。报错信息往往不是“No Access-Control-Allow-Origin header”而是Access to XMLHttpRequest at https://api.example.com/data from origin http://localhost:3000 has been blocked by CORS policy: Response to preflight request doesnt pass access control check: No Access-Control-Allow-Origin header is present on the requested resource.注意看描述里的关键词“to preflight request”这就说明问题出在预检阶段而不是真实请求阶段。排查方向也要跟着变。2.3 响应头里必须出现的几个字段CORS 相关的响应头很容易让人混乱因为实在太多。我整理一份常用清单方便对照响应头作用常见错误Access-Control-Allow-Origin指定允许读取响应的来源设置为*或反射未校验的 OriginAccess-Control-Allow-Methods预检时声明允许的 HTTP 方法漏掉PUT、DELETEAccess-Control-Allow-Headers预检时声明允许的自定义请求头漏掉Authorization、Content-TypeAccess-Control-Allow-Credentials是否允许携带 Cookie 凭证设置为true时Allow-Origin不能用*Access-Control-Expose-Headers允许前端 JS 读取的自定义响应头漏掉X-Total-Count、X-TokenAccess-Control-Max-Age预检结果的缓存时长设得过大开发时难以调试Vary: Origin告知缓存系统响应随 Origin 变化缺失导致 CDN/浏览器缓存串号这几个字段的关系是Access-Control-Allow-Origin是所有跨域请求的“通行证”预检请求还需要Access-Control-Allow-Methods和Access-Control-Allow-Headers一旦涉及 Cookie 会话就得配合Access-Control-Allow-Credentials。所有配置必须成套出现漏一个都可能让联调陷入“怎么调都不对”的死循环。3. 通配符*的三大隐患安全、凭证与缓存3.1 任意网站都能读你的接口响应把Access-Control-Allow-Origin设置成*字面意思是“任何来源都能读取”。如果你做的接口是纯公开数据比如天气接口、公告接口那问题不大。一旦接口里涉及用户数据哪怕是“经验分享列表”这种看起来不敏感的数据只要它带上了登录用户的身份信息*就会变成安全隐患。攻击场景是这样的用户访问了一个恶意网站该网站的脚本向你的接口发起请求。因为你的响应头声明了*浏览器认为这个读取操作是合法的脚本就能拿到响应内容。如果接口在识别到登录态时返回了用户的部分隐私信息那这些数据就泄露了。你可能觉得“那我后端接口判断一下用户权限不就行了”但问题在于Access-Control-Allow-Origin: *意味着任何来源都能在浏览器里读取你的响应这在权限层面的比赛规则里已经很被动。正确做法是在服务端维护一份可信任来源清单只有清单里的 Origin 才允许读取。3.2 带 Cookie 的请求直接白屏关于*和 Cookie 的组合CORS 规范里有明确限制当Access-Control-Allow-Credentials: true时Access-Control-Allow-Origin不允许使用*必须指定具体来源。原因很好理解允许携带 Cookie 的场景本质上是服务端相信“这个请求确实来自可信的前端页面”并且带着用户凭证。如果你同时允许所有来源那恶意网站也能用用户的 Cookie 发起请求读取响应。浏览器为了防止这种风险直接规定只要带凭证Origin 就必须精确指定。实际开发中报错会长这样The value of the Access-Control-Allow-Origin header in the response must not be the wildcard * when the requests credentials mode is include.这是最经典的“前端联调翻车”现场。前端用axios.post(url, data, { withCredentials: true })或者fetch(url, { credentials: include })后端设置了Access-Control-Allow-Origin: *浏览器就直接拒绝响应被拦截页面表现为接口失败或白屏。正确逻辑是后端识别到请求带凭证模式时返回跟请求 Origin 一致的具体来源并且加上Access-Control-Allow-Credentials: true。换句话说从配置*的那一刻起你已经放弃了携带凭证的跨域请求如果你还需要 Cookie 会话这个方案从根上就不成立。3.3 缺少Vary: Origin引发的缓存串号这是很多人没注意到却最容易引发线上事故的隐患。讲一个真实的场景你在 CDN 或浏览器缓存里存了一份请求/api/profile的响应这份响应是携带了Access-Control-Allow-Origin: https://www.example.com的。此时另一个用户从https://shop.example.com访问同一个接口如果缓存系统认为响应是通用的直接把上一份带 CORS 头的响应返回了那浏览器就会认为这个响应只能给https://www.example.com读取于是拦截页面报 CORS 错误。很多团队的现状是后端动态反射了 Origin却没有同步输出Vary: Origin。Vary的作用就是告诉缓存系统这个响应会根据请求的Origin不同而不同不能直接复用。缺失Vary: Origin的时候CDN 和浏览器缓存都默认响应是跟 Origin 无关的于是就可能出现“同一个接口有时能通有时报 CORS 错误”的间歇性故障。即便你把Access-Control-Allow-Origin写成*我也建议你加上Vary: Origin因为*虽然理论上对所有来源一视同仁但一旦后续你想改成白名单方案缺少Vary的老缓存会成为隐患。多一行Vary: Origin就能避免一类非常隐蔽的缓存问题。4. 正确配置方案按场景给可直接抄的代码4.1 单一固定域名简单但不推荐的原因如果你只有一个固定的前端域名最直接的方式是写死// Node.js / Express 示例 app.use((req, res, next) { res.setHeader(Access-Control-Allow-Origin, https://www.example.com); res.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); res.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization); res.setHeader(Access-Control-Allow-Credentials, true); res.setHeader(Vary, Origin); if (req.method OPTIONS) { res.sendStatus(204); return; } next(); });这种配置的优点是逻辑简单一眼就能看懂。缺点也很明显只有一个来源可以访问以后加了管理后台比如admin.example.com就得改代码。不利于多环境部署。测试环境、预发环境、生产环境的 Origin 都不同写死在代码里容易维护成灾难。如果想支持“本地开发用 localhost线上用正式域名”写死方案会非常痛苦。所以我的建议是“单个固定域名”只适合临时联调或者确定永不加域名的内部工具正式项目尽量用白名单动态反射。4.2 白名单动态反射校验后再回显 Origin所谓“动态反射”就是后端读取请求头里的Origin经校验合法后再把这个值作为Access-Control-Allow-Origin返回。核心代码大致是这个样子const allowedOrigins [ https://www.example.com, https://admin.example.com, http://localhost:3000, ]; function isAllowedOrigin(origin) { if (!origin) return false; try { // 使用 URL 解析防止被绕过 return allowedOrigins.includes(new URL(origin).origin); } catch { return false; } } app.use((req, res, next) { const origin req.headers.origin; if (origin isAllowedOrigin(origin)) { res.setHeader(Access-Control-Allow-Origin, origin); res.setHeader(Vary, Origin); } res.setHeader(Access-Control-Allow-Methods, GET, POST, PUT, DELETE, OPTIONS); res.setHeader(Access-Control-Allow-Headers, Content-Type, Authorization); res.setHeader(Access-Control-Allow-Credentials, true); if (req.method OPTIONS) { res.sendStatus(204); return; } next(); });这里有两个细节要特别强调第一不要直接反射未经校验的 Origin。有些教程会写res.setHeader(Access-Control-Allow-Origin, req.headers.origin)这是非常危险的做法等于任何人都能伪造一个 Origin 让服务端放行。这里说的“伪造”不是浏览器端的伪造——浏览器发跨域请求时Origin 由浏览器自动带上恶意网站无法伪造但攻击者用 curl、Postman 或者服务器端脚本构造请求时能随意指定 Origin 头。如果你的接口是给服务器之间调用的直接反射等于告诉攻击者“你爱是谁就是谁”。第二白名单匹配时别用includes或endsWith做宽松匹配。比如用origin.endsWith(example.com)的时候http://evil-example.com会被误判为合法来源。即便你只想匹配子域名也应该用new URL(origin).hostname后再做精确匹配或严格的后缀向下匹配注意example.com前缀是.。安全校验要从严不要为了省几行代码给攻击者留口子。4.3 需要携带 Cookie 时的完整配置携带 Cookie 的场景下除了Access-Control-Allow-Origin必须是具体值之外前端也要配合。以 axios 为例axios.defaults.withCredentials true;以 fetch 为例fetch(https://api.example.com/user, { credentials: include, });如果前端没有开启凭证模式后端就算设置了Access-Control-Allow-Credentials: true浏览器也不会在跨域请求里带上 Cookie。完整的后端示例就又回到 4.2 的白名单动态反射。需要跟前端确认带凭证的跨域请求要求前端的withCredentials和credentials必须与后端配置匹配否则行为不一致是最难排查的坑。比如前端用 fetch 发了credentials: same-origin默认值跨域请求就不会带 Cookie后端看到的是未登录状态表现就是“接口通了但拿不到用户信息”。4.4 Nginx 网关层统一配置与add_header的继承坑很多团队习惯在 Nginx 这一层统一处理跨域这样后端不用每个项目都配置一遍。一个典型的配置如下map $http_origin $cors_origin { default ; ~^https?://(www\.)?example\.com$ $http_origin; ~^https?://admin\.example\.com$ $http_origin; ~^https?://localhost:3000$ $http_origin; } server { listen 443 ssl; server_name api.example.com; if ($cors_origin) { add_header Access-Control-Allow-Origin $cors_origin always; add_header Vary Origin always; } # 预检请求直接返回 if ($request_method OPTIONS) { add_header Access-Control-Allow-Methods GET, POST, PUT, DELETE, OPTIONS always; add_header Access-Control-Allow-Headers Content-Type, Authorization always; add_header Access-Control-Max-Age 86400 always; return 204; } location / { proxy_pass http://backend; } }这里有两个 Nginx 的经典坑坑一add_header的继承规则并不是“父层设置了子层就一定有”。如果你的location块里自己写了add_header那么该location内不会继承 server 层级的add_header。也就是说你在 server 层加了 CORS 头但某个 location 里又加了add_header X-Frame-Options那个 location 的 CORS 头就“凭空消失”了。解决办法是在所有需要的地方都显式加上或者用统一的一段配置模板。坑二if块在 Nginx 里容易产生难以预料的继承问题。上面的写法在简单的 server 场景下是可行的但如果你有复杂的location嵌套建议把预检请求的处理放到单独配置片段里include避免所有逻辑挤在一起。我一般更推荐直接用 lua 或后端应用处理预检请求Nginx 只处理响应头反射。另外一个容易被忽略的点跨域请求如果经过了网关的rewrite或proxy_pass响应头的增加时机很重要。有些团队会把add_header写在location的proxy_pass之前但响应头实际是后端生成后再经过 Nginx 的add_header必须在代理响应返回给客户端之前执行这个顺序通常没有问题但要注意如果后端已经返回了同名响应头Nginx 默认不会覆盖除非用always或其他指令这时浏览器看到的可能还是后端自己的值。4.5 Spring Boot 等框架的实现方式如果你的后端是 Java Spring Boot可以更框架化地配置 CORS。Spring Boot 2.x 高版本和 3.x 的推荐做法如下Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns( https://www.example.com, https://admin.example.com ) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(Content-Type, Authorization) .allowCredentials(true) .maxAge(3600); } }注意这里用的是allowedOriginPatterns而不是allowedOrigins。如果你既想使用allowCredentials(true)又想配置多个来源Spring 的allowedOrigins会在这个组合下报错因为规范不允许*与凭证共存。allowedOriginPatterns是 Spring 5.3 之后提供的解决方案它允许在带凭证的情况下使用通配符模式例如https://*.example.com。唯一需要留意的是allowedOriginPatterns虽然支持通配符但通配符只能匹配域名的一部分不能直接写成*否则就失去了白名单的意义。团队内部要约定清楚“可以用模式匹配子域名但绝不能放行所有来源”。其他框架的思路也一致。Python Flask里的flask-cors、Go 里的github.com/rs/cors本质上都是封装了“校验 Origin 精确回显 预检响应”这套逻辑关键参数都是那几个。5. 排查链路从No Access-Control-Allow-Origin header开始5.1 先判断有没有发预检请求Network 面板说话拿到 CORS 报错之后第一步不是看后端代码而是打开浏览器 DevTools 的 Network 面板找到失败的那个请求看它的请求行信息。如果请求方法是OPTIONS说明这是一个预检请求。此时关注三个问题预检请求返回了什么状态码如果返回 401、403、405说明后端认为这个请求不合法或直接拒绝了OPTIONS方法需要在后端把OPTIONS当“安全请求”放行。预检响应里有没有Access-Control-Allow-Methods和Access-Control-Allow-Headers如果少了这两个头浏览器会继续报“doesnt pass access control check”。预检响应里有没有Access-Control-Allow-Origin如果没有或者值跟当前 Origin 不匹配后续真实请求就别想发了。如果请求方法是GET/POST/PUT没有经过预检那就直接看响应头。响应头里没有Access-Control-Allow-Origin基本可以确定是服务端没有配置或者配置没生效。5.2 预检通过但实际请求失败别忽略响应头大小写和层级有一种情况很隐蔽预检请求成功返回了Access-Control-Allow-Origin也在响应头里但真实请求依然报 CORS 错误。常见原因是后端在“真实请求”的响应里没有加 CORS 头。比如有些后端代码只对OPTIONS请求统一加头却忘了在业务响应里也加上。或者出现异常时响应走了全局异常处理器CORS 头的增加逻辑被跳过了。还有一种情况是响应头的 key 拼写错误比如写成了Access-Control-Allow-Origin却手滑敲成Access-Control-Allow-Origin少了一个字母浏览器只看标准头名任何拼写错误都视为没有该头。另外响应头虽然大小写不敏感但 Nginx 的add_header和某些网关配置的变量如果没取到值就不会输出这个头。判断方法是直接在 Network 面板里查看响应头确认浏览器到底收到了什么。浏览器说“no header”那就真的没收到不要凭印象觉得“我明明加了”。5.3 缓存/CDN 场景下的“间歇性 CORS 异常”“一会儿正常一会儿报错”是最合适的缓存串号信号。排查思路如下固定来源 A 请求接口第一次正常清缓存后第二次正常。这说明响应本身是正常的是缓存把“正确响应”替换成了“异常响应”。换成来源 B 请求接口如果复现了 CORS 报错多半是缓存里存的是来源 A 的响应直接发给了来源 B。解决办法给所有动态反射 Origin 的跨域响应加上Vary: Origin并在 CDN 配置里启用Vary: Origin缓存。如果 CDN 不支持Vary协商缓存那就只能把 CORS 头放到 CDN 不缓存的路径上或者用真实后端响应绕过缓存层。还有一个容易被忽略的点浏览器的本地缓存里也有 Preflight 预检缓存。Access-Control-Max-Age决定了预检结果能在浏览器缓存多久。开发时为了调试方便建议设置一个短时间比如 600 秒等上线了再调大到 86400 秒。不然改完后端配置浏览器还在用老结果会让人误以为没改对。5.4 一张排查清单收尾我把这么多年排查 CORS 问题时积累的经验整理成一张清单遇到报错时按顺序走一遍大多数问题都能定位步骤检查项判断方法1请求是简单请求还是预检请求Network 面板里看有没有 OPTIONS 请求2后端是否正确处理 OPTIONS预检请求是否返回 2xx3响应头是否包含Access-Control-Allow-OriginDevTools 响应头面板直接查看4该值是否与请求 Origin 精确匹配对比大小写和结尾斜杠5是否携带 CookieAccess-Control-Allow-Credentials必须为true且 Origin 不能是*6预检时是否声明了自定义请求头Authorization、X-Custom-Header是否在Allow-Headers里7是否有Vary: Origin尤其当接入 CDN 后出现间歇性异常8后端异常响应是否也加了 CORS 头全局异常处理里补上同一个加头逻辑这张清单最核心的价值在于先确认浏览器到底收到了什么再根据收到的东西反推配置问题。不要对着代码猜凡是浏览器能看到的都以 Network 面板为准。最后说一点个人体会。CORS 配置并不是一个“一劳永逸”的工作项目从开发、测试到上线的每个环节域名都会变跨域策略也要跟着演进。与其在项目初期图省事用*不如一开始就把白名单和Vary: Origin的配置结构搭好。后面加新域名、接 CDN、开泛解析都只是在配置列表里加一行的事。踩过几次坑之后你会发现CORS 配置最难的从来不是写代码而是把所有边缘场景都想清楚开发环境、测试环境、生产环境、自定义头、Cookie、缓存、网关层和框架层的配置是否冲突。把这些都理清了跨域问题基本就不会再主动找上你。