
项目标题是浏览器控制台里最常见的那行红字Access to XMLHttpRequest at http://localhost:8080/api/user/login from origin http://localhost:5173 has been blocked by CORS policy。这篇就围绕它展开。CORS、跨域、同源策略、预检请求、XMLHttpRequest 这些词只要你写过后端接口、调过前后端分离的项目早晚会撞上。它不是什么高深的东西但足够让人卡半天——尤其对刚上手前后端分离的同学接口在 Postman 里明明通浏览器里死活发不出去很容易怀疑人生。我准备把它拆成几个部分先讲清这条报错到底在说什么再讲怎么三分钟判断问题出在谁身上然后分别从前端代理、后端配置、网关入口三个层面给可直接抄的配置最后是排查速查表和这几年反复踩到的坑。无论你是刚接触前后端分离的新手还是已经写过几个项目的开发者应该都能从里面拿到能直接用的东西。1. 这条红色报错到底在说什么1.1 同源策略浏览器给页面画的一条安全边界先把概念摆正。CORS 全称 Cross-Origin Resource Sharing中文一般叫跨域资源共享它是浏览器整套同源策略体系里的一个出口机制。所谓“源”origin由协议、域名、端口三部分组成三者完全一致才叫同源。http://localhost:5173和http://localhost:8080协议一样、域名一样但端口不一样所以它们是两个不同的源彼此之间的请求就是跨域请求。浏览器为什么要管这件事想象一下你在某个网站上登录了网银登录态存在 Cookie 里。如果没有同源策略你随后打开的任意一个恶意页面都能用你的身份向网银的后台发请求浏览器还会自动把 Cookie 带上那你的账户就形同虚设。同源策略本质上是在限制“哪个源的脚本能读哪个源的响应数据”它保护的是数据读取权限不是请求发送能力。这一点特别关键也是很多人理解偏差的来源浏览器默认并不阻止请求发出去它阻止的是你把响应内容读回来。所以你会看到 Network 面板里请求状态是 200Response 里也有完整数据但控制台还是报 CORS 错误JS 里 catch 到的也只有一个信息量极少的网络错误。这是因为浏览器拿到响应之后检查了响应头里有没有允许你这个源访问的声明没找到就把响应内容对脚本屏蔽掉同时打一条红线提醒你。理解了这个机制后面所有的解决方案就都顺了要么让服务器在响应头里明确声明“我允许这个源访问”要么让请求根本不跨域。1.2 请求在生命周期的哪一步被拦下很多人以为 CORS 是服务器端拦截其实是浏览器端的判定。完整流程大致是这样页面脚本发起 XMLHttpRequest 或 fetch浏览器先把请求发到目标服务器对于简单请求服务器正常处理并返回浏览器收到响应后读取响应头中的Access-Control-Allow-Origin和当前页面的源做比对。匹配则把响应交给脚本不匹配就拦截并抛出错误。这解释了一个经典困惑后端日志里明明有这条请求的记录接口也正常返回了但前端就是拿不到数据。因为请求确实到达了后端、后端也确实处理了只是浏览器在把结果交给 JS 这一步做了拦截。你去看后端日志找问题方向从一开始就错了。对于非简单请求流程还要多一步。浏览器会先发一个OPTIONS预检请求不带业务数据只问服务器三件事这个源能不能访问、能不能用这些方法、能不能带这些请求头。服务器用Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers回答。预检通过了浏览器才发真正的 POST 或 PUT。如果预检没过你在 Network 面板里只会看到一个OPTIONS请求挂掉真正的业务请求压根没发出去这也是很多人找不到自己那条 POST 记录的原因。1.3 Postman 通、浏览器不通的真相新手最容易被这个现象带偏。用 Postman 或者 curl 请求同一个接口返回 200、数据完整于是断定“后端没问题是前端写错了”。这个结论一半对一半错。对的部分是前端确实可能有配置问题错的部分是Postman 这类工具根本不是浏览器没有同源策略这层限制。它们发起请求时不带 Origin 头也不做响应头的白名单校验服务器返回什么它们就展示什么。所以这类工具只能验证接口逻辑是否正确完全验证不了跨域配置是否生效。反过来说如果你在 Postman 里也不通那才真的是后端接口本身的问题跟 CORS 无关。我见过不少团队在联调阶段用接口工具自测通过就宣布接口就绪结果前端一接全红。后来我们把“用浏览器实际页面试一次”写进了联调检查项。这个习惯看起来啰嗦能省掉大量来回扯皮的时间。2. 先别急着改后端三分钟定位问题归属2.1 从 Network 面板读出真实信息控制台那行报错信息量其实很有限真正有用的是 Network 面板。按 F12 打开勾选保留日志复现一次操作然后看这几件事。第一看请求有没有出现在列表里。如果连请求记录都没有说明可能是 URL 拼错了、被浏览器扩展拦了或者页面在发请求之前就抛异常了跟 CORS 无关。第二看有没有两条记录一条OPTIONS一条POST。有OPTIONS且它返回 4xx 或没有 CORS 响应头问题在预检没通过重点查后端的Allow-Methods和Allow-Headers。只有POST且状态 200 但控制台报错问题在正式响应缺Allow-Origin。第三点开响应头直接搜access-control-。这一搜结果基本就定案了一个都没有说明 CORS 配置压根没生效有一个但值不对说明匹配逻辑写错了有多个重复的Access-Control-Allow-Origin那就是多层代理各自加了一遍浏览器会因为值不唯一而拒绝。我一般习惯先看响应头再看别的。这个动作十秒钟比翻代码找配置快得多。2.2 简单请求与预检请求的分界线搞清楚哪些请求会触发预检能帮你判断为什么有的接口正常、有的接口报错。判断标准是请求方法是 GET、HEAD、POST 之一并且请求头只包含浏览器自动设置的少数几个安全头Accept、Accept-Language、Content-Language、Content-Type 的有限取值并且 Content-Type 只能是application/x-www-form-urlencoded、multipart/form-data、text/plain三者之一。全部满足才是简单请求不触发预检。现实项目里几乎没人用这三种 Content-Type。前后端分离项目默认发 JSONContent-Type: application/json直接越过简单请求的边界必然触发预检。你在请求里加个自定义头比如Authorization或者X-Tenant-Id同样触发预检。这就能解释一个很常见的现象登录接口报跨域但另一个查询接口好的。可能是因为查询接口用的是 GET 且没带自定义头而登录接口是 POST 加 JSON body。不要以为“同一个后端配置应该一样”要按请求特征分开看。请求特征是否预检典型场景GET无自定义头否列表查询、详情页POST application/json是登录、新增、编辑带Authorization头是带令牌的鉴权接口带自定义业务头是多租户、灰度标记PUT / DELETE / PATCH是RESTful 风格接口2.3 五种高频触发场景对照我把这几年遇到的跨域场景归了归类基本逃不出下面五种认出来是哪一种解法就定了。第一种本地开发前后端分开跑。前端 5173后端 8080最典型的场景就是标题里这条报错。解法优先用开发服务器代理。第二种本地前端连测试环境后端。域名完全不同代理依然是最省事的路子。第三种生产环境前后端同域部署但路径不同比如前端在/接口在/api。这种情况下如果还报跨域通常是网关把接口转发到了另一个域名或者前端把请求地址写成了绝对 URL 而不是相对路径。第四种多环境配置不一致。测试环境配了通配符生产环境配了白名单但漏了新域名上线才发现。第五种第三方回调或者嵌入式页面。请求来源是别人家的域名属于不可控源需要在后端做动态白名单判断。对照一下自己属于哪一类再往下看对应章节比漫无目的地改代码效率高得多。3. 前端侧的解法开发服务器代理3.1 Vite 与 Webpack 的代理配置实测开发阶段最推荐的做法是代理理由是它不需要后端配合改的是本地开发服务器行为也不影响生产部署。原理很简单浏览器请求发到同源的开发服务器开发服务器在 Node 进程里把请求转发给后端Node 之间没有同源策略限制所以不存在跨域。响应原路返回给浏览器浏览器看到的是同源响应一切正常。Vite 的配置长这样写在vite.config.js里import { defineConfig } from vite export default defineConfig({ server: { port: 5173, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, // 后端接口本身带 /api 前缀这里不要 rewrite // 如果后端接口没有前缀用 rewrite 去掉 // rewrite: (path) path.replace(/^\/api/, ) } } } })changeOrigin: true这个参数很关键它会把转发请求的 Host 头改成目标地址的 Host。有些后端或者网关会根据 Host 做路由或校验不开这个参数容易收到 404 或者 400。至于rewrite要看后端接口的真实路径后端是/api/user/login那就别 rewrite后端是/user/login前端写/api/user/login那就得 rewrite 掉前缀。这两个配置搞反了会出现代理成功但后端返回 404 的情况报错从跨域变成了找不到资源一样让人头大。Webpack Dev Server 的写法是module.exports { devServer: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }配好之后前端代码里所有请求都要写相对路径/api/user/login绝不能写http://localhost:8080/api/user/login。这一点是新手最容易翻车的地方代理配了代码里还是写死的绝对地址浏览器照样直连 8080照样跨域。代理只对相对路径生效因为只有相对路径才会被发到开发服务器上。我建议把接口基地址统一抽到一个环境变量文件里开发环境留空让请求走相对路径生产环境再填真实域名。3.2 代理的代价与不适用的场景代理不是银弹用之前得知道它的边界。第一个限制是它只存在于开发服务器打包上线之后代理配置就没了。所以生产环境必须另有一套方案通常是同域部署加网关转发或者后端做 CORS 白名单。第二个限制是它会让“真实请求路径”和“你写的路径”不一致。团队里如果没人维护这份约定新人接手时看到代码里写/api/xxx会以为后端真的挂在/api下去后端代码里找半天找不到。解决办法是在 README 里写清楚转发规则或者干脆让前后端路径保持一致别做 rewrite。第三个限制是 WebSocket、文件上传的进度事件在某些代理配置下行为会有差异。大文件上传的场景我更倾向于后端直接开 CORS少一层转发就少一处排查点。还有一点代理解决的是浏览器到开发服务器这一段如果后端本身还有一层网关要做域名校验代理也救不了。所以配完代理还报错要去看后端和网关的日志确认请求到没到、被谁拒了。3.3 携带凭证时的两个必踩坑只要涉及登录态代理配置和 CORS 配置都会多出一层复杂度。前端如果用 axios需要显式打开withCredentialsconst service axios.create({ baseURL: /api, timeout: 10000, withCredentials: true })不打开这个浏览器不会把 Cookie 带上后端拿到的会话是空的表现为“登录成功但下一个接口 401”。打开之后问题往往变成另一个预检请求被拒。原因在于一旦请求携带凭证浏览器对响应头的要求会变严格Access-Control-Allow-Origin不能是*必须是具体的源同时必须返回Access-Control-Allow-Credentials: true。这两条同时满足浏览器才肯把响应交给脚本。很多教程只写了加Allow-Credentials没提通配符的问题照着写就是不通。代理解法下也有坑。代理转发时 Cookie 的 Domain 和 Path 可能对不上导致浏览器不认。开发阶段我更推荐用令牌方案登录成功后把 token 存起来通过Authorization头传递而不是依赖 Cookie 会话。这样既避开了凭证相关的跨域细节前后端分离也更彻底。4. 后端侧的解法Spring Boot 的 CORS 配置4.1 全局配置的写法与每个参数的含义如果后端是 Spring Boot最推荐全局配置一次写好所有接口都受益。写法是提供一个WebMvcConfigurer的实现import org.springframework.context.annotation.Configuration; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(http://localhost:*, https://your-domain.com) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .exposedHeaders(X-Request-Id) .allowCredentials(true) .maxAge(3600); } }一个参数一个参数说清楚这些都是我实际调试过的。addMapping(/**)表示对所有路径生效。如果只想给/api/**开就写/api/**范围越小越安全。allowedOriginPatterns是支持通配符的版本allowedOrigins是精确匹配。带端口的本地开发地址端口经常变用 patterns 更省事。注意allowedOriginPatterns(http://localhost:*)这种写法在较新的 Spring 版本里才支持老版本没有这个方法要用allowedOrigins精确列出。allowedMethods一定要包含OPTIONS。漏了这个预检直接被拒正式请求发不出去。这是最高频的配置遗漏。allowedHeaders(*)一般够用但如果前端带了自定义头且后端框架版本较老通配符可能不生效需要显式列出来比如Content-Type, Authorization, X-Token。exposedHeaders管的是另一件事默认情况下前端 JS 只能读到响应头里的少数几个简单头自定义头读不到。如果你需要前端读X-Request-Id做链路追踪必须在这里声明否则前端response.headers[x-request-id]永远是 undefined。maxAge(3600)是预检结果缓存时间单位秒。设成 3600 意味着一小时内同一接口的预检不用重发。不设的话每次请求都要多一个OPTIONS往返接口多的时候性能影响不小。我一般设 3600开发阶段会调小到 60 方便改配置后快速生效。4.2 注解方式与拦截器方式的边界CrossOrigin注解适合局部场景比如只有一个开放接口要暴露给外部页面调用。写法是加在 Controller 类或者方法上RestController RequestMapping(/api/user) CrossOrigin(origins http://localhost:5173, maxAge 1800) public class UserController { PostMapping(/login) public Result login(RequestBody LoginDTO dto) { // 登录逻辑 } }它的好处是作用域清晰坏处是分散。项目里接口一多哪个配了哪个没配很难一眼看清出问题时排查成本高。我现在的习惯是统一走全局配置只有当某个接口需要完全不同的策略比如对外开放的第三方接口需要放开更多源才用注解覆盖并且加注释说明原因。还有一种常见做法是用过滤器或者拦截器手动写响应头Component public class CorsFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletResponse response (HttpServletResponse) res; HttpServletRequest request (HttpServletRequest) req; String origin request.getHeader(Origin); if (origin ! null origin.matches(^http://localhost:\\d$)) { response.setHeader(Access-Control-Allow-Origin, origin); response.setHeader(Access-Control-Allow-Credentials, true); response.setHeader(Access-Control-Allow-Methods, GET,POST,PUT,DELETE,OPTIONS); response.setHeader(Access-Control-Allow-Headers, Content-Type,Authorization); response.setHeader(Access-Control-Max-Age, 3600); } if (OPTIONS.equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); return; } chain.doFilter(req, res); } }这种写法灵活度最高能做动态白名单判断但要注意两点一是必须在chain.doFilter之前设置头否则响应已经提交就改不了了二是要处理OPTIONS直接返回不要让它继续走到业务逻辑里去否则可能因为没有 body 而抛参数校验异常。这里有个非常容易踩的坑全局配置和拦截器同时存在时响应头会被加两遍浏览器看到两个Access-Control-Allow-Origin直接判定配置非法。我在一个项目里就遇到过原因是团队合并代码时两个人都加了跨域处理一个用配置类一个用过滤器谁也不报错前端就是不通。排查方法是看响应头数量超过一个就是重复了。4.3 通配符与凭证的互斥问题这条单独拎出来说因为它太常见了。allowedOrigins(*)配上allowCredentials(true)在 Spring 里会直接启动报错或者请求时抛异常浏览器端看到的就是跨域失败。原因很直观如果允许任意源携带 Cookie那任何恶意站点都能拿到用户数据等于把同源策略废了。正确的做法是动态回显请求的 Origin。用allowedOriginPatterns而不是allowedOriginsSpring 会自动把匹配上的具体源回填到响应头里而不是返回一个星号。上面那段配置里我写的就是这个思路。如果你手写过滤器逻辑就是读请求头里的 Origin判断是否在允许列表内在就把它原样写回Access-Control-Allow-Origin。千万别图省事直接写*。还有一个隐含的问题不带 Origin 头的请求比如浏览器直接地址栏访问、服务端之间调用Origin 是 null。这时候要不要返回 CORS 头我的处理是不返回因为同源请求本来就不需要返了反而可能引发某些客户端的解析问题。判断逻辑里加个 null 校验就够了。5. 网关与生产环境Nginx 与统一入口5.1 Nginx 层的响应头配置生产环境常见的部署形态是前端静态资源和一个反向代理放在一起接口通过/api前缀转发到后端服务。这种形态下前后端是同域的理论上没有跨域问题。但如果前端页面和接口最终落在不同域名下或者前端用了绝对地址直连后端就还是要在 Nginx 层加头。配置大致是这样server { listen 80; server_name your-domain.com; location /api/ { if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin $http_origin always; 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-Allow-Credentials true always; add_header Access-Control-Max-Age 3600 always; return 204; } add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Credentials true always; proxy_pass http://backend_service; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }几个细节值得说。$http_origin是 Nginx 内置变量取请求头里的 Origin 值用它回显比写死安全得多。always参数保证响应头在 4xx、5xx 状态码下也会被加上否则接口一报错前端看到的就变成跨域错误掩盖了真正的业务错误排查时特别折磨人。预检请求用return 204直接短路不要让OPTIONS转发到后端。原因有两个一是省一次后端调用二是某些后端框架对OPTIONS请求的处理不友好容易返回 405。proxy_set_header Host $host这行也重要。有些后端会根据 Host 生成跳转地址或者做签名校验不传的话可能拿到内网地址导致重定向出错。5.2 重复响应头导致报错变形这是我踩过最隐蔽的坑之一。现象是本地好好的上了测试环境就报跨域而且报错信息里提示Access-Control-Allow-Origin包含多个值。查了半天发现是链路里加了两遍网关加了一遍后端应用自己也加了一遍。浏览器对响应头的容忍度很低Access-Control-Allow-Origin只允许出现一个值出现两个哪怕值相同就判定无效。解决方案是确定唯一负责加头的位置。我的建议是如果架构里有网关就在网关统一加应用层不再处理如果没有网关就在应用层加Nginx 不加。另一个变形情况是用add_header时没加always后端返回 500 的时候头就消失了前端看到跨域实际是后端挂了。这个坑会严重误导排查方向——你会一直去查跨域配置其实配置根本没问题是业务代码抛异常了。所以我的做法是先看后端日志确认接口本身是否正常返回再判断是不是跨域问题。5.3 生产白名单的维护方式生产环境千万不要放开通配符。允许的来源应该是明确的域名列表写在配置中心或者环境变量里跟着发布走。我一般会维护这样一份配置cors: allowed-origins: - https://admin.your-domain.com - https://www.your-domain.com - https://m.your-domain.com然后后端读取这份列表用allowedOriginPatterns注入。多环境的话测试环境多一份测试域名开发环境放开localhost:*。需要提醒的是白名单里写的是完整的源包括协议和端口。写your-domain.com不匹配https://your-domain.com写https://your-domain.com:443在某些浏览器判定下也和https://your-domain.com不严格相等。稳妥的做法是不带默认端口。上线前我会做一件事用浏览器实际访问一次把前端调用的所有接口过一遍重点看带自定义头和带 Cookie 的那几个。配置文件里看不出来的问题浏览器一跑就露馅。这一步花五分钟能避免上线后的大量客诉。6. 常见问题排查速查与踩坑记录6.1 问题与解法速查表现象大概率原因处理方向控制台报 CORSNetwork 里没有请求请求未发出地址或代码异常检查 URL 拼接、页面是否提前报错只有 OPTIONS 记录返回 4xx预检未通过检查 Allow-Methods 是否含 OPTIONS、Allow-Headers 是否覆盖自定义头响应 200 但控制台仍报错响应缺 Allow-Origin 或值不匹配对比 Origin 与配置值注意协议和端口提示 Allow-Origin 有多个值多层重复加头定位到唯一负责加头的一层带 Cookie 的请求失败通配符与凭证冲突改回显具体 Origin加 Allow-Credentials本地好、线上报错白名单漏了新域名更新生产环境来源列表接口报错时变成跨域错误错误响应没带 CORS 头Nginx 加 always或统一错误处理里补头前端读不到自定义响应头未声明 exposedHeaders后端追加该头6.2 三个反复出现的坑第一个坑是路径前缀。前端写/api/user/login后端配的映射是/user/login中间没做 rewrite结果代理转发到 404。这个错误的表现不是 CORS而是 404但新手容易混为一谈以为是跨域没配好。判断方法是看 Network 里请求的完整 URL 和后端日志里的访问路径对不对得上。第二个坑是改了配置没重启。Java 应用改配置类不重启不会生效Vite 改了 proxy 配置有时候需要重启 dev server 才认。我见过在配置里加了半天实际跑的还是旧进程。养成改完就重启的习惯或者先确认热更新是否覆盖了配置文件。第三个坑是配置文件被覆盖。开发环境为了图快在代码里写了宽松配置发布时忘了切到生产配置或者多个配置文件加载顺序不对导致实际生效的是错误的那份。解决办法是把跨域配置和来源列表完全外置成环境变量不同环境注入不同值代码里不写死任何域名。6.3 关于是否该用通配符的判断有些团队在测试环境直接用*觉得反正不是生产。我的看法是要区分场景不带凭证的开放接口测试环境放开通配符问题不大但一旦涉及登录态和用户数据通配符会让测试结果失真——你测出来的行为和线上真实行为不一样很多凭证相关的问题要到上线才暴露。更麻烦的是通配符会掩盖配置错误。比如你把来源写错了域名通配符照样放行你以为配置对了换到生产环境换成正则匹配立刻全挂。我现在统一的策略是开发环境用localhost:*模式匹配测试环境用明确的测试域名列表生产环境用明确的生产域名列表。三套配置结构一致只有值不同这样每一次环境切换本身就是一次配置校验。最后说一个实践中的体会。跨域这个问题之所以烦不在于它难而在于报错信息太模糊把所有可能性都压缩成一行红字。真正高效的排查永远是从响应头和 Network 面板入手先确定请求到没到、头有没有、值对不对再决定改哪一层。我自己的习惯是遇到这类报错先花三十秒看响应头比直接翻代码强得多——很多时候问题就摆在那几个缺失的access-control-字段里看清楚了改哪一行就是明摆着的事。