
在接手一个老系统的时候被 Sa-Token 过滤器引发的 CORS 误判问题卡了两天。前端反复报“跨域”、后端死活说“我配了CORS”最后定位到元凶竟然是 Sa-Token 的过滤器把 OPTIONS 预检请求给拦截了。这个问题在前后端分离的项目里很典型也很隐蔽因为很多人会把问题误解为“CORS 没有配置”或者“Nginx 反代问题”很少往认证体系的过滤器链路上想。这篇文章我就结合我的实际排错过程把 Sa-Token 过滤器如何干扰 CORS、为什么会出现误判、以及最终的修复方案完整拆解一遍。适合看这篇文章的主要是正在用 Sa-Token 做权限控制的 Java 开发者尤其是前后端分离、存在跨域调用的团队。如果你恰好遇到过“后端明明配了跨域但前端就是调不通”的情况这篇文章能帮你省掉至少半天排查时间。1. 问题现场CORS 报错背后的真实链路1.1 报错表现与第一轮误判当时前端同事反馈所有需要携带登录态请求的接口都调不通浏览器 Console 里报的是一段很经典的 CORS 错误Access to XMLHttpRequest at http://api.xxx.com/user/info from origin http://admin.xxx.com has been blocked by CORS policy.后端这边我第一反应是查全局跨域配置确认 Spring MVC 的WebMvcConfigurer里确实加了addCorsMappings而且allowedOriginPatterns、allowedMethods、allowedHeaders都配了。再查网关或者 Nginx发现转发也没问题。当时心里浮起一个念头是不是前端带了自定义 Header触发了 CORS 的“非简单请求”预检流程而我们漏了什么配置结果一遍遍核对配置都没有看出问题。甚至我还让前端试过不带Authorization头结果普通请求能通一带头就挂。这个现象其实已经在强烈暗示问题出在认证过滤器对 OPTIONS 预检请求的处理上而不是 Spring 的 CORS 配置本身。1.2 请求被拦截的现场还原我用浏览器的 Network 面板和后端日志两头一起抓终于看到了完整链路前端发送真实请求前浏览器先发送OPTIONS /user/info。这个 OPTIONS 请求带着Origin: http://admin.xxx.com、Access-Control-Request-Method: GET、Access-Control-Request-Headers: satoken等头。后端 Sa-Token 过滤器在 Spring 的 CORS 处理之前先执行发现 OPTIONS 请求中没有有效的satokenSa-Token 默认从请求头或 Cookie 读取 Token于是直接判定为未登录返回一个 JSON 格式的 401 响应。浏览器收到的这个 401 响应里没有Access-Control-Allow-Origin等 CORS 响应头于是浏览器认定跨域失败。我把这个链路展开讲一下因为这是整个问题的核心。浏览器在发起“非简单请求”之前会先做一次预检预检请求本身并不携带业务数据它纯粹是为了询问服务器“你允不允许我这个跨域请求”。Sa-Token 的过滤器恰恰把这种询问当成了“必须登录才能访问”的正式业务请求于是直接按“未登录”处理。这个返回的 401 体里没有半点 CORS 响应头浏览器不认账把整次请求判定为跨域失败而真正的业务请求根本没有发出去。2. 根因剖析为什么配置了 CORS 依然跨域失败2.1 CORS 预检请求的触发条件很多人会把 CORS 简单理解为“服务器在响应头里加几个字段就完事”但实际上浏览器遵循的是一套完整的协议。对于“简单请求”也就是只使用 GET、POST、HEAD且只携带常见几个 Header 的请求浏览器会当作简单请求直接发出服务端只要在响应里带上Access-Control-Allow-Origin就能通过。一旦请求满足下面任何一个条件浏览器就会先发送 OPTIONS 预检使用了 PUT、DELETE、PATCH 等方法携带了自定义请求头比如satoken、Authorization、X-Token请求体不是application/x-www-form-urlencoded、multipart/form-data、text/plain中的一种。绝大多数使用 Token 认证的前后端分离项目都会在请求头里塞一个自定义 Token 字段。这几乎等于必然触发预检。也就是说只要用了 Sa-Token 又会跨域调用你的项目 100% 会遇到 OPTIONS 请求。这不是偶然而是必然。2.2 Sa-Token 过滤器在链路中的位置Servlet 规范中的过滤器链和 Spring MVC 的拦截器不是同一个东西。Sa-Token 一般通过SaServletFilter或自定义 Filter 注册进过滤器链并且会先于 Spring MVC 的 HandlerInterceptor 执行。这意味着 Sa-Token 的过滤器在请求抵达 Controller 之前就已经生效了也会早于 Spring 对 CORS 配置的最终处理。正常情况下你配置的addCorsMappings会在 Spring MVC 处理请求的早期阶段写入 CORS 响应头。但 Sa-Token 的过滤器更靠前它在 CORS 处理代码还没机会运行的时候就返回 401 了。响应直接终止后续所有逻辑都不会执行。这就是为什么“CORS 配置看起来完全正确却依然失效”的根本原因。打个比方你把门口的安检机器放到了快递柜前面任何包裹都必须先过安检然后快递柜才有机会被打开。一个纯粹是“询问能不能投递”的包裹OPTIONS 请求被安检机器当成“无人认领的正式包裹”给拒收了快递柜自然永远不会被使用。2.3 为什么凭直觉很难发现这个问题很难排查是因为报错信息极具迷惑性。浏览器只报 CORS 错误不会告诉你“服务器返回的是 401”。而后端日志里也不会出现一个扎眼的异常因为在 Sa-Token 看来你就是一个没登录的人想访问需要认证的接口拒绝得合情合理。要不是我盯着 Response Headers 看了半天看到返回的根本不是 CORS 响应头而是一段未登录的 JSON我可能还要在错误的方向上浪费更多时间。另一个迷惑点是“普通接口能通带认证的接口不通”。这也不能怪开发者粗心因为普通接口如果不需要登录Sa-Token 过滤器通过excludeLoginPaths放行了也就不存在拦截问题。需要登录的接口才会被拦截看起来就像是“权限配置漏了”而不是“过滤器把 OPTIONS 给吃了”。3. 方案选型四种解决思路的取舍3.1 方案一在 Sa-Token 过滤器里直接放行 OPTIONS最直白的做法是在 Sa-Token 过滤器配置的addExcludePaths中把/v1/**之类的路径排除掉或者对所有 OPTIONS 请求直接放行。示例代码大概是这样的Configuration public class SaTokenConfigure { Bean public SaServletFilter saServletFilter() { return new SaServletFilter() .addInclude(/**) .addExclude(/auth/login, /auth/logout) .setAuth(obj - { // 判断是否登录 StpUtil.checkLogin(); }) .setError(e - { // 返回错误信息 }); } }可以调整setAuth里的逻辑在调用StpUtil.checkLogin()之前先判断一下是不是 OPTIONS 请求是的话直接 return.setAuth(obj - { if (OPTIONS.equals(SaHolder.getRequest().getMethod())) { return; } StpUtil.checkLogin(); })这个方案简单直接能立刻解决预检请求被拦截的问题。它的缺点是会放过所有 OPTIONS 请求而 OPTIONS 请求本身并不是真正执行业务逻辑的请求所以安全风险很小顶多增加一点点无效请求的透传。很多团队最终都会选择这个方案因为它改动最小效果立竿见影。3.2 方案二调整过滤器顺序让 CORS 先执行在 Spring Boot 中可以通过Order注解控制 Filter 的执行顺序。理论上如果把 CORS Filter 的顺序排在 Sa-Token 之前CORS 响应头就会被先写入即使 Sa-Token 后续返回 401浏览器也能看到 CORS 头跨域问题就不会被误判。Bean Order(Ordered.HIGHEST_PRECEDENCE) public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); }这个方案比方案一更“正统”因为它解决的是“CORS 处理时序”这个本质问题。但实际使用中要注意一个细节Sa-Token 的过滤器可能是通过SaServletFilter自动注册的也可能是在项目里手动 new 出来的顺序不好统一控制。如果 Sa-Token 的过滤器顺序靠前光在代码里给CorsFilter加Order可能还压不住它。这时候得看 Sa-Token 的注册机制不同版本的 Sa-Token 处理方式略有差异。我在项目里曾见过同事用WebFilterOrder的组合结果还是没生效。后面查资料才发现 Sa-Token 的SaServletFilter内部有自己的注册逻辑优先级未必单纯由Order控制。所以除非你确实理解了 Sa-Token 当前版本的过滤器注册源码否则我不建议你依赖这个方案。3.3 方案三在全局异常/响应处理中补充 CORS 头这个思路是在 Sa-Token 的setError回调中或者在一个自定义的 OncePerRequestFilter 中统一对响应附加 CORS 响应头.setError(e - { SaHolder.getResponse() .setHeader(Access-Control-Allow-Origin, *) .setHeader(Access-Control-Allow-Methods, *) .setHeader(Access-Control-Allow-Headers, *); return SaResult.error(e.getMessage()); })这样做的好处是不用放行 OPTIONS 请求也不需要调整过滤器顺序只要 Sa-Token 返回错误CORS 头照样带上去。浏览器看到响应里有 CORS 头跨域校验就通过了虽然业务请求最终还是会因为未登录而 401但不会把它误报成 CORS 错误。这个方案看起来很漂亮但它有一个容易被忽略的坑响应头里的 CORS 必须和前端、后端配置保持一致。如果一开始用了*但业务上又要携带 Cookie 或者需要使用allowedCredentials那么Access-Control-Allow-Origin就不能写成*否则浏览器会拒绝。你说一个本来就因为跨域问题被搞到焦头烂额的人还要再维护一份手工响应头风险是不是有点大3.4 方案四真正常规的解法组合我最终采用的是方案一 方案二的变体组合。具体做法是这样的在 Sa-Token 的setAuth里判断OPTIONS直接放行。同时注册一个全局CorsFilter使用 Spring 的CorsConfiguration配置所有跨域许可。在最外层再包一个OncePerRequestFilter对所有响应统一补充Vary: Origin等头预防某些反代/缓存的坑。组合起来的代码放在后面第 4 章节这里我想先说清楚选型理由放行 OPTIONS 是解决“Sa-Token 拦截预检”的唯一直接手段而全局CorsFilter负责把真正的 CORS 响应头正确写出去。两者各司其职不会出现“放行了 OPTIONS 但 CORS 头没补上”这种半吊子状态。4. 实操完整修复步骤与验证流程4.1 步骤一先确认是不是 Sa-Token 过滤器的问题在动手改代码前建议先用两个快速验证确认问题定位避免白干打开浏览器开发者工具看预检请求 OPTIONS 的响应状态码。如果返回 401 或 200 但响应内容是一段 JSON比如{code:401,msg:未登录}基本可以确认是 Sa-Token 拦截了预检请求。临时在 Sa-Token 配置里把所有需要认证的接口路径排除掉再让前端试一次。如果跨域报错消失说明就是 Sa-Token 的认证逻辑在作祟。我在实际排错时还用过一个小技巧用命令行直接发一个 OPTIONS 请求。curl -i -X OPTIONS \ -H Origin: http://admin.xxx.com \ -H Access-Control-Request-Method: GET \ -H Access-Control-Request-Headers: satoken \ http://api.xxx.com/user/info返回的响应头里如果根本没有Access-Control-Allow-Origin同时响应体是一个 401 的 JSON那就不用再怀疑 Spring 的 CORS 配置了。4.2 步骤二修改 Sa-Token 过滤器配置我以 Sa-Token 1.3x 版本为例最终落地代码基本是这样Configuration public class SaTokenConfig { Bean public SaServletFilter saServletFilter() { return new SaServletFilter() .addInclude(/**) // 登录、刷新 token 等接口排除 .addExclude(/auth/login, /auth/sms) .setAuth(obj - { // 核心预检请求直接放行不参与登录校验 String method SaHolder.getRequest().getMethod(); if (OPTIONS.equalsIgnoreCase(method)) { return; } // 校验登录状态 StpUtil.checkLogin(); }) .setError(e - { // 这里也可以顺手把CORS响应头补上作为双保险 SaHolder.getResponse() .setHeader(Access-Control-Allow-Origin, *) .setHeader(Access-Control-Allow-Headers, *) .setHeader(Access-Control-Allow-Methods, *); return SaResult.error(e.getMessage()); }); } }有几个细节值得注意。SaHolder.getRequest().getMethod()在 Sa-Token 的封装里取到的是当前请求的 HTTP 方法字符串注意转成大写再比较因为某些客户端可能会发小写的options。还有就是addExclude和setAuth里的放行逻辑是不同层面的前者是根本不会进入认证逻辑后者是进入了认证逻辑但主动跳过。对于这个场景两种都可以我习惯用setAuth内放行因为addExclude写太多路径对后续维护不友好。4.3 步骤三配置全局 CORS 过滤器这一步不能让 Spring 自生自灭最好显式注册一个CorsFilter并且Order要足够靠前。Configuration public class CorsConfig implements WebMvcConfigurer { Bean Order(Ordered.HIGHEST_PRECEDENCE) public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.setAllowCredentials(true); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }为什么用addAllowedOriginPattern(*)而不是addAllowedOrigin(*)因为allowCredentials(true)场景下Access-Control-Allow-Origin不能是*必须回显具体的 Origin。Spring 的addAllowedOriginPattern(*)会动态匹配当前请求的 Origin 并写到响应头里这样既允许跨域又能携带 Cookie。maxAge设置成 3600 秒是为了让浏览器缓存预检结果避免每个请求都来一次 OPTIONS对接口性能有点帮助。不过要提醒一下如果你设置了maxAge在修复期间最好把它调成 0 或很小否则浏览器会缓存旧的预检失败结果导致你改了代码前端还是旧的跨域错误。我就在这个细节上吃了亏改完代码后前端过了五分钟才正常一度以为自己的修复没有生效。4.4 步骤四前端联调验证服务端修好后不能光看后端日志就宣布完成。我通常会让前端做三类验证带自定义 Token 头的跨域 GET 请求带自定义 Token 头的跨域 POST 请求JSON 请求体需要携带 Cookie 的跨域请求。每一项都要求浏览器控制台不出现 CORS 报错同时 Network 面板里可以看到预检请求 OPTIONS 的状态码是 200 或 204并且响应头里包含Access-Control-Allow-Origin和Access-Control-Allow-Headers。我自己的测试习惯是先用 Chrome 的无痕窗口避免本地插件或者缓存干扰。然后再用 Firefox 的开发者工具里的“网络”面板复验一遍因为两个浏览器对 CORS 的提示风格不一样双验证能降低“当前浏览器过了、用户浏览器挂了”的概率。5. 常见问题与排查技巧实录5.1 为什么我明明放了 OPTIONS前端还是报跨域这个情况我碰到过两次。一次是放行逻辑写在了addExclude里但路径写的是/user/*而实际请求是/user/info/detailSpring 的AntPathMatcher并不是按你想的那样匹配多级路径。另一次是浏览器缓存了旧的预检失败结果。处理办法也很简单把放行路径改成/**或者/user/**同时把maxAge改小等联调通过后再调大。如果有 Nginx 在中间层还需要检查一下 Nginx 是否对 OPTIONS 请求做了额外处理。有些运维同学会在 Nginx 层直接return 204这种情况下 CORS 响应头由 Nginx 决定而不是后端 Java 程序决定这里的“CORS 误判”就未必是 Sa-Token 的锅了。5.2 使用自定义 Token 头时要注意 Header 大小写问题浏览器在发送Access-Control-Request-Headers时会使用你代码里setRequestHeader时写的大小写形式。Sa-Token 默认读取请求头的名字是satoken但如果你前端写的是Satoken或者Token虽然 HTTP 规范说 Header 名称不区分大小写但在某些浏览器兼容场景下后端拿到的结果可能不一致。稳妥的做法是前端统一项目里也统一约定一个 Header 名别一会儿satoken、一会儿Authorization。另外如果使用Authorization头承载 TokenSa-Token 也支持通过配置token-name切换。但 CORS 预检时Access-Control-Allow-Headers里必须包含这个 Header 名否则浏览器依然会拦截。用allowedHeader(*)时这个问题不明显一旦你想收紧策略就很容易漏。5.3 多个过滤器叠加时的排查顺序很多项目里不只是 Sa-Token 一个过滤器可能还有日志过滤器、XSS 过滤器、自定义鉴权过滤器。如果多个过滤器对 OPTIONS 请求的处理逻辑互相冲突最糟糕的情况会表现为“CORS 时而通、时而不通”。这时候不妨临时把所有自定义过滤器都关掉看只剩下 Spring 自带的 CORS 时是否正常然后再一个个加回来。这种二分法虽然原始但效率极高我靠它解决过好几起“过滤器链相互打架”的疑难杂症。5.4 从“跨域报错”快速定位到“认证拦截”我想分享一个更好的排查思路不要先看 CORS 配置而要先看响应体。在浏览器控制台里点开那条红色的跨域报错切到 Network找到对应的预检请求或业务请求查看响应体内容。如果响应体是项目自己定义的统一返回结构比如{code:401,msg:未登录}那问题几乎 100% 出在服务端的认证过滤器上。这个技巧对任何权限框架都适用不只针对 Sa-Token。有过一次前端把请求放在fetch里且设置了credentials: include后端没有开启allowCredentials结果浏览器直接拒绝。那一次我就没看响应体因为响应体压根没有后来加上allowCredentials(true)并配合allowedOriginPattern(*)才通过。所以我想强调一句CORS 问题排查的第一步永远是“分清楚是浏览器没放行还是服务端没返回正确响应头”。6. 写在最后的个人体会踩过 Sa-Token 过滤器引发 CORS 误判这个坑之后我最大的改变是写认证过滤器时默认就会考虑 OPTIONS 预检请求的兼容问题而且会把 CORS 配置外置成一个明确的配置类不再指望 Spring Boot 的自动配置能兜底。这套思路后来用到了其他几个项目上都稳稳当当。如果你正被“前端报跨域、后端说已配置”的诡异问题折磨我的建议是别急着改代码先用上面 4.1 里的校验方法确认 Sa-Token 是否拦截了 OPTIONS。确认后直接在第 4.2 和 4.3 的代码基础上落地大部分场景都能一次解决。最后记得把浏览器缓存和预检缓存一起清掉再做完整的跨域联调。这样一通操作下来你会发现自己不仅解决了问题还顺带把 CORS 和过滤器链的底层逻辑都理清了以后再遇到类似问题基本就是送分题了。