
之前维护一个前后端分离的项目登录拦截器上线第二天测试同事就拿着手机找过来前端明明带了 token为什么接口全挂在跨域错误上我打开后端日志一看满屏都是 OPTIONS 请求被 401 拦下来的记录。这个问题后来我在多个项目里都遇见过几乎成了 Spring Boot 登录拦截器联调阶段的标配坑。这篇文章就把 OPTIONS 预检请求的来龙去脉、登录拦截器放行方案、CORS 配套配置和实际踩坑经验一次性说清楚适合正在做前后端分离授权体系、被预检请求折腾过的后端同学参考。1. 先搞清楚 OPTIONS 预检请求是怎么来的1.1 浏览器为什么会主动发一个 OPTIONS跨源请求要能正常发出去浏览器会先做 CORS 检查。CORS 的全称是 Cross-Origin Resource Sharing服务端需要通过响应头告诉浏览器“我这个接口允许哪些来源访问、允许哪些方法、允许哪些请求头”。平时我们用 Postman、curl 调试接口没感觉是因为这些工具不受同源策略约束只有浏览器会严格执行这一套流程。当页面前端和后端不在同一个源比如前端跑在http://localhost:5173后端跑在http://localhost:8080浏览器发现这是一个跨源请求就会先判断请求是不是“非简单请求”。如果是浏览器不会直接把请求发出去而是先发送一个OPTIONS 预检请求等预检通过后才发送真正的业务请求。哪些情况会触发预检请求方法用了PUT、DELETE、PATCH等非简单方法请求头是application/json而不是application/x-www-form-urlencoded、multipart/form-data或text/plain请求里带了自定义请求头比如Authorization、X-Token、X-Requested-With这里要特别提醒一句很多人以为只有 POST 请求才会触发 OPTIONS其实只要请求里带了Authorization这种自定义头哪怕是 GET 请求也会触发预检。这恰恰是登录业务里最常见的场景因为登录后的接口几乎都要带 token。1.2 登录拦截器在这一步栽了什么跟头登录拦截器的逻辑通常很简单拦截所有/api/**请求检查请求头里的 tokentoken 有效就继续无效就返回 401。问题在于浏览器发送的 OPTIONS 预检请求一般不会带业务 token它里面主要是Origin、Access-Control-Request-Method、Access-Control-Request-Headers这三个头目的是问服务端“我能不能用这个方式请求你”。拦截器一看请求头里没有 token二话不说返回 401。浏览器收到 401 后认为预检失败于是真正的业务请求压根不会发出。前端看到的错误五花八门最常见的是跨域错误因为浏览器的开发者工具里会显示Access-Control-Allow-Origin缺失有时候也会显示Network Error、Request failed with status code 401。这里的关键点在于拦截器会把所有匹配路径的请求都拦下来它不关心这个请求是不是浏览器发起的预检请求。所以问题的根源不是“OPTIONS 请求为什么这么烦”而是“拦截器没有区分预检请求和真实业务请求”。解决方案就是显式放行 OPTIONS 请求但放行不是简单地在拦截器里写一个return true就完事它还要和 CORS 配置配合起来否则就算放行了预检响应里没有正确的 CORS 头浏览器照样不认账。2. 放行方案怎么选过滤器、拦截器、还是配置里直接解决2.1 三层机制的作用范围在 Spring Boot 里一个请求从进入到被业务方法处理会经过多个层次每一层都有办法处理 OPTIONS 请求处理层级实现方式特点Servlet Filter实现Filter或继承OncePerRequestFilter在 Spring MVC 的拦截器之前执行能覆盖所有请求包括静态资源和错误页HandlerInterceptor实现HandlerInterceptor在preHandle中判断作用在 Controller 方法调用之前是登录鉴权最常使用的层级Spring Security在安全配置里permitAll()OPTIONS 方法只适用于引入了 Spring Security 的项目安全框架先于业务拦截器处理请求全局 CORS 配置通过WebMvcConfigurer.addCorsMappings或CorsFilter负责生成 CORS 响应头也能直接拦截预检请求对登录拦截器来说最容易想到的做法是在自己的preHandle方法里判断request.getMethod()如果是 OPTIONS 就直接返回 true。这个方案直观也能解决大部分问题。但它有个隐藏风险如果项目里有多个拦截器比如一个登录拦截器、一个权限拦截器、一个日志拦截器其他拦截器未必会放行 OPTIONS请求可能在你这个拦截器通过了却在下一个拦截器被卡住。还有一条路是在过滤器层面处理。过滤器执行顺序在拦截器之前如果写一个CorsFilter遇到 OPTIONS 请求直接设置 CORS 头并返回 200请求根本到不了拦截器。这个方案最彻底也最不容易受到其他拦截器影响。缺点是你会多维护一段过滤器代码而且如果没处理好过滤器顺序可能连静态资源请求都受影响。如果项目用了 Spring Security还可以在安全配置里对 OPTIONS 统一放行。注意这只能保证 Spring Security 这一层不拦截你自己写的登录拦截器依然需要处理 OPTIONS因为它是另一个独立链路。2.2 我最终采用的组合拳我的习惯是登录拦截器自身显式放行 OPTIONS 全局 CORS 配置。具体原因有三个第一拦截器放行 OPTIONS 的代码量最小不影响其他 Filter 和 Security 配置改动风险低。第二全局 CORS 配置能保证预检响应里带有正确的响应头这是“放行”行为真正有效的必要条件。只放行不配 CORS等于告诉前端“你可以过来”但门卫却不开门。第三把 CORS 和拦截器配置集中到同一个WebMvcConfigurer里后续维护的人一眼就能看出项目里做了哪些跨域处理和放行逻辑。如果你当前项目里已经有专门的统一过滤器比如网关层、日志链路过滤器那也可以把 OPTIONS 放行放在过滤器里从源头挡掉。无论选哪种方案核心原则是预检请求不能走业务鉴权逻辑也不能因为业务 token 缺失而返回错误。3. 完整实操登录拦截器放行 OPTIONS 的落地代码3.1 准备依赖与基础配置这里以 Spring Boot 2.7 为例3.x 的写法基本一致只是javax.servlet要换成jakarta.servlet。项目里先确保有spring-boot-starter-web依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency如果你用 JWT 做 token再额外引入对应的解析库比如jjwt。这一步不放具体版本以你自己项目里实际使用的为准。为了方便演示我在application.yml里加一个配置项用来控制是否放行 OPTIONSserver: port: 8080 app: security: skip-options: true这个配置项后面会用到。提前这样做的好处是某些环境比如纯后端联调或单元测试时你可以关闭放行看看拦截器自身的防护逻辑是否正常工作。3.2 登录拦截器与 OPTIONS 放行登录拦截器实现HandlerInterceptor核心逻辑放在preHandle方法里Component public class AuthInterceptor implements HandlerInterceptor { Value(${app.security.skip-options:true}) private boolean skipOptions; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 关键放行 OPTIONS 预检请求 if (skipOptions OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } String token resolveToken(request); if (token null || token.isEmpty()) { writeUnauthorized(response, 未登录或 token 为空); return false; } Long userId parseTokenAndGetUserId(token); if (userId null) { writeUnauthorized(response, token 无效或已过期); return false; } request.setAttribute(userId, userId); return true; } private String resolveToken(HttpServletRequest request) { String authorization request.getHeader(Authorization); if (authorization ! null authorization.startsWith(Bearer )) { return authorization.substring(7); } return authorization; } private Long parseTokenAndGetUserId(String token) { // 这里按你自己的鉴权逻辑实现 // 比如解析 JWT、从 Redis 查会话等 // 返回 null 代表校验失败 return null; } private void writeUnauthorized(HttpServletResponse response, String message) throws IOException { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\ message \}); } }这段代码有几个细节值得说清楚。skipOptions从配置里读取默认值为 true保证没有配置时也能正常放行。判断条件用OPTIONS.equalsIgnoreCase(request.getMethod())意思是大小写不敏感符合 HTTP 方法名大小写不敏感的约定。我特意最后传入了 token 校验逻辑的占位方法parseTokenAndGetUserId。实际项目里这一步可能是调用 JWT 解析工具也可能是查 Redis还可能要和数据库里保存的会话状态比对。不管哪种方式只要返回 null拦截器就返回 false前端拿到 401 后再走重新登录逻辑。这里有个容易踩的坑如果 token 校验逻辑本身有异常比如解析 JWT 时抛出了ExpiredJwtException拦截器会把这个异常直接抛出去导致返回 500 而不是 401。严谨的做法是在parseTokenAndGetUserId内部用 try-catch 包一圈把所有解析异常都转换成“校验失败”的结论。这虽然不是 OPTIONS 问题本身但属于登录拦截器这一整套逻辑里常见的连带问题。3.3 注册拦截器时的几个关键动作有了拦截器还要把它注册进 Spring MVC。新建一个配置类实现WebMvcConfigurerConfiguration public class WebMvcConfig implements WebMvcConfigurer { Resource private AuthInterceptor authInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns(/api/**) .excludePathPatterns( /api/public/**, /api/login, /error ); } }这里要说的第一个关键点是excludePathPatterns是按照 URL 路径来排除的它不能按 HTTP 方法排除。所以如果有人告诉你“你直接在排除路径里加一个 OPTIONS 就完事了”那是行不通的。这也是为什么我们必须在preHandle里判断request.getMethod()而不是试图用路径匹配来解决问题。第二个关键点是注册顺序。如果项目里还有别的拦截器记住一个原则放行 OPTIONS 的拦截器要尽量靠前注册。InterceptorRegistry.addInterceptor的调用顺序决定了拦截器链中的执行顺序先注册的先执行。如果你先注册了一个权限拦截器再注册 OPTIONS 放行拦截器OPTIONS 请求会被权限拦截器先挡住后面的放行逻辑根本没机会执行。这个坑我会在第 4 节再展开说。第三个关键点是路径范围。很多项目会在addPathPatterns里直接写/**这样会把 Spring Boot 默认的错误页/error、静态资源等都拦截掉。虽然我们可以在excludePathPatterns里补上/error但更推荐把拦截范围控制在/api/**这类业务路径下既能避免误伤也能让代码语义更清晰。3.4 CORS 配置要和放行逻辑配合起来拦截器放行 OPTIONS 只是第一步预检响应还必须带上正确的 CORS 响应头。在同一个WebMvcConfigurer里加上addCorsMappingsOverride public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE, PATCH, OPTIONS) .allowedHeaders(Authorization, Content-Type, X-Requested-With) .exposedHeaders(Authorization) .allowCredentials(true) .maxAge(3600); }这套配置里allowedOriginPatterns(*)表示允许所有来源allowCredentials(true)表示允许跨源携带 Cookie。这里有一个 Spring Boot 2.4 之后才开始浮现的细节如果你用了allowCredentials(true)就不要用allowedOrigins(*)因为这种组合会被拒绝浏览器会给出“不允许使用通配符作为源同时又要允许携带凭证”的错误。正确做法是用allowedOriginPatterns(*)它能在携带凭证的场景下继续支持通配符。allowedMethods里一定要有 OPTIONS。有些项目只写了 GET、POST、PUT、DELETE结果预检请求明明走到了 CORS 处理器但服务端返回的Access-Control-Allow-Methods里没有 OPTIONS浏览器同样会判定预检失败。这个属于很低级但很常见的配置漏项。allowedHeaders列出的是预检请求中允许携带的请求头。实际项目里如果前端用了自定义头比如X-Company-ID、X-Lang要记得加进去。偷懒一点可以直接写allowedHeaders(*)在 Spring Boot 3.x 里也可以但列明具体字段的好处是让接口的安全边界更清晰。maxAge(3600)表示预检结果可以缓存 1 小时。设置这个值可以有效减少浏览器的预检请求次数对接口响应速度和前端体验都有明显帮助。尤其是页面上有大量接口并发请求时如果没有maxAge每个跨源请求都会先发一次 OPTIONS控制台里会看到很长一串预检记录光是网络开销都比较可观。根据我的经验这个值设置在 600 到 3600 秒之间比较合理设得太短体验不好设成一天又可能在服务端 CORS 规则调整后让浏览器继续使用旧的缓存不走新规则。这里再回答一个很多人会问的问题我已经在拦截器里放行 OPTIONS 了也配了addCorsMappings为什么拦截器返回 401 时跨域响应头还是不对原因是Spring MVC 的 CORS 处理器在预检阶段确实会先处理 OPTIONS 请求并添加响应头但请求进入业务拦截器后如果拦截器返回 false 并以response.setStatus(401)收尾没有手动去设置Access-Control-Allow-Origin浏览器依然看不到可用的跨域头。也就是说业务请求失败时服务端也需要在响应头里明确允许跨源前端才能读到 401 状态和错误信息。很多项目里都会再写一个CorsFilter或者在异常处理阶段统一设置 CORS 头就是这个原因。3.5 前端联调时的实际效果配置完成后前端浏览器里请求登录后的接口网络面板会这样走浏览器先发 OPTIONS 请求后端返回 200响应头里包含Access-Control-Allow-Origin、Access-Control-Allow-Methods、Access-Control-Allow-Headers。浏览器确认预检通过再发真实的 GET 或 POST 请求。这个真实请求会带上Authorization头后端拦截器正常校验 token。token 有效则返回业务数据无效则返回 401前端再根据 401 做跳转登录页的处理。用 curl 手动模拟预检请求也很简单这样可以跳过浏览器缓存干扰直接验证后端行为curl -i -X OPTIONS \ -H Origin: http://localhost:5173 \ -H Access-Control-Request-Method: POST \ -H Access-Control-Request-Headers: Authorization, Content-Type \ http://localhost:8080/api/order/list如果命令执行后能看到HTTP/1.1 200且响应头里有Access-Control-Allow-Origin: http://localhost:5173说明服务端预检处理正常。如果返回的是 401说明拦截器或者 Security 配置里还有一层没有放行顺着响应头里的信息逐层排查即可。前端 axios 侧的常见配置片段长这样axios.post(/api/order/list, data, { headers: { Authorization: Bearer token, Content-Type: application/json } }).then(res { // 业务处理 })这里真正要注意的是Content-Type一旦指定成application/json就必然会触发预检和Authorization无关。所以如果前面配置没做好登录之后第一个业务请求就会挂在 OPTIONS 上这个现象出现频率非常高几乎成为了登录拦截器联调阶段的标志性问题。4. 实战中踩过的坑照着这张表避4.1 只放行 OPTIONSCORS 头却没跟上我有一次帮同事排查问题拦截器里已经写了 OPTIONS 放行逻辑浏览器依然报跨域。后来发现他的拦截器里放行 OPTIONS 后直接返回 true但项目里完全没有配置addCorsMappings也没有任何 CorsFilter所以预检响应里自然没有Access-Control-Allow-Origin头。浏览器拿不到这个头就判定服务端不允许跨源访问。解决办法是补全 CORS 配置。如果不想用WebMvcConfigurer的写法也可以显式声明一个CorsFilterBean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.setAllowedOriginPatterns(List.of(*)); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); config.setMaxAge(3600L); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); }注意如果你同时注册了CorsFilter又在WebMvcConfigurer.addCorsMappings里配了内容有可能会出现重复的 CORS 响应头。浏览器遇到两个相同的Access-Control-Allow-Origin头时按规范会做特殊处理有些场景下不会报错但更稳妥的做法是只选其中一种配置方式。除非你很清楚自己在做什么否则不要叠加。4.2 拦截器注册顺序不对OPTIONS 还是被卡上一个项目里我维护的拦截器链除了登录拦截器还有一个操作权限拦截器。当时我在登录拦截器里已经放行了 OPTIONS但权限拦截器在注册顺序上排在了前面OPTIONS 请求先被权限拦截器拦截权限拦截器又要求从请求体里读取用户角色信息结果读不到数据直接返回 403。排查过程其实挺折磨人的因为登录拦截器日志里根本没有 OPTIONS 请求记录但前端确实一直在报错。后来我把权限拦截器临时注释掉问题马上消失才定位到是拦截器链顺序问题。解决办法有几种把放行 OPTIONS 的判断逻辑下沉让每个拦截器在preHandle开头都判断一次OPTIONS统一放行。用过滤器在链路最前端处理预检请求从源头截断。注册时把包含 OPTIONS 放行逻辑的拦截器放在第一位。从可维护性上看我推荐第二种方案也就是写一个简单的OncePerRequestFilter统一处理 OPTIONS 和 CORS 头数据流更清晰也不用担心后面新增拦截器时忘记放行。4.3 同时用了 Spring Security两层都拦如果项目里同时引入了 Spring Security 和自定义登录拦截器情况会变成OPTIONS 请求要穿过两层检查任何一层没放行都会前功尽弃。常见的 Spring Boot 3 项目安全配置里需要这样写Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth - auth .requestMatchers(HttpMethod.OPTIONS, /**).permitAll() .requestMatchers(/api/login, /api/public/**).permitAll() .anyRequest().authenticated() ) .csrf(csrf - csrf.disable()) .cors(Customizer.withDefaults()); return http.build(); }重点主要有两个一是.requestMatchers(HttpMethod.OPTIONS, /**).permitAll()让所有 OPTIONS 请求越过 Spring Security 的登录认证检查。二是.cors(Customizer.withDefaults())让 Spring Security 使用 Spring MVC 配置的 CORS 处理器。如果这行不写安全过滤链可能不会主动处理 CORS 预检后面就算 permitAll 了响应头也未必正确。即便 Security 这一层放行了你自己写的登录拦截器依然会执行所以两层放行逻辑要同时存在。这类项目的报错排查会更复杂我的经验是先确认 Security 这一层放行再确认业务拦截器放行最后再检查 CORS 头。4.4 常见问题速查表现象可能原因解决方向前端报跨域后端日志里有 OPTIONS 但没进入业务接口登录拦截器没有放行 OPTIONS在preHandle开头判断OPTIONS并返回 true拦截器放行了但还是报 CORS 错误项目没有配置 CORS 响应头加addCorsMappings或CorsFilterAccess-Control-Allow-Origin使用*时和allowCredentials(true)冲突配置方式不符合 CORS 规范改用allowedOriginPatterns(*)Access-Control-Allow-Methods缺少 OPTIONSallowedMethods里漏配把OPTIONS加进去多个拦截器时 OPTIONS 仍被卡放行逻辑所在拦截器顺序靠后将放行逻辑放到拦截器链最前或用 Filter 统一处理同时使用 Spring Security 仍报跨域Security 层没有放行 OPTIONS在安全配置里.requestMatchers(HttpMethod.OPTIONS, /**).permitAll()401 响应里没有跨域头前端看不到具体错误信息业务拦截器返回 401 时没有设置 CORS 头在统一异常处理或在拦截器返回 401 时补充 CORS 头5. 三个容易忽略的细节5.1 放行 OPTIONS 不等于放开鉴权这个概念需要反复强调OPTIONS 预检请求本身并不包含业务数据它不是真正的接口调用。给 OPTIONS 放行只是告诉浏览器“我这个接口允许你这套跨源组合方法”真正的业务请求依然要经过拦截器的 token 校验。所以不要在放行代码里顺手把 GET、POST、PUT、DELETE 也放行了那会导致整个鉴权形同虚设。正确的逻辑边界是拦截器只对request.getMethod()为 OPTIONS 的请求跳过 token 校验其他所有方法都按正常鉴权流程走。这个边界写得很清楚后续代码审查时也不容易引发争议。5.2 自定义请求头是预检触发的主因很多人会把预检请求和登录接口“为什么每次都要发两次请求”联系起来。其实只要前端用application/json提交数据或者自定义了业务头浏览器就会把 OPTIONS 预检和真实请求分成两次发出去。这是一种标准行为不是后端配置出了 bug也不应该试图通过修改前端去消除预检。有些团队为了让请求变成“简单请求”会把Content-Type改成text/plain或者把自定义请求头塞到 query 参数里。这样确实能减少一次预检但坏处也很明显一方面不标准另一方面等于把业务信息暴露在 URL 里安全性和可读性都下降。我认为不值得为了省一次 OPTIONS 请求而破坏 HTTP 语义服务端把 OPTIONS 放行做好才是正道。5.3 将放行规则做成配置项我前面给出的代码里已经包含了一个app.security.skip-options配置项。这个配置项在平时看着多余等到做安全加固或者写自动化测试时优势就出来了。比如你想写一个单元测试来验证“拦截器对缺少 token 的任意请求都会返回 401”但你又不想绕过 OPTIONS 放行逻辑去测试就可以把配置项设为false让 OPTIONS 请求也走拦截器逻辑。测试完再切回来。这个开关让代码在不同运行环境下有了更强的可调整性属于一个成本很低但收益明确的细节。另外如果你在调试某些边缘问题时临时想看看“OPTIONS 以外的请求会不会被误放行”也可以基于这个开关做切换很快就能确认行为是否符合预期。配置项默认值设为true是为了保证多数正常业务不受影响但每个团队的安全策略不同自己权衡即可。我在实际项目里的做法是把 OPTIONS 放行和 CORS 配置放在同一个WebMvcConfigurer里维护成本最低。每次新增团队成员遇到跨域问题我给的第一个排查命令永远是curl -X OPTIONS带上Origin和Access-Control-Request-Method头几秒钟就能判断服务端行为是否正常。最后分享一个小建议前端在封装请求工具时把Authorization、Content-Type这类请求头统一管理后端把预检放行和 CORS 头规则固定下来两侧约定好了这类问题基本就不会再来烦人了。