1. 先搞明白AccessDeniedException到底是谁在什么时候抛出来的1.1 那个“不允许访问”的提示到底是从哪来的看到org.springframework.security.access.AccessDeniedException这个异常第一反应应该是你的项目已经成功进入了 Spring Security 的授权链路而不是被网络层、网关或者前端路由给拦下来的。这其实是好事至少说明问题有明确的方向可以查怕的是那种“啥日志都没有页面就白屏了”的情况。先说说“不允许访问”这几个字从哪来。做过前后端分离项目的人应该都见过Spring Security 默认的英文提示是Access Denied也就是访问被拒绝。如果你在页面上看到的是中文“不允许访问”那大概率是项目自己做了两件事中的一件要么是全局异常处理器比如RestControllerAdvice把异常 catch 住之后对外输出了一段统一的中文提示要么是前端在拿到 HTTP 403 状态码后统一弹了一个“不允许访问”的 toast。所以我建议你拿到这个问题后第一件事先去确认“不允许访问”是后端返回的 JSON还是前端自己渲染的文案。这个细节直接决定你该去查接口还是查前端路由能省不少时间。我个人的习惯是出现这类异常先翻后端日志看有没有完整的异常栈。如果日志里有一行org.springframework.security.access.AccessDeniedException: Access is denied那么后端一定抛了异常接下来只需要顺着这条线往下排查。如果后端日志干净得很那就要去前端找原因了比如路由守卫里写了角色判断、菜单权限没配置、按钮权限控制导致某个跳转被拦截等等。这两种情况的排查路径完全不同却经常被混为一谈。1.2 从请求进来到异常抛出中间发生了什么要理解AccessDeniedException可以先把它放回 Spring Security 的完整流程里看。一个 HTTP 请求打进来会先经过一堆过滤器Filter这些过滤器在 Spring Security 里被组织成一条过滤器链。其中我们最关心的三个角色是认证过滤器负责解析 Token、Session、Cookie把当前用户身份放到 SecurityContext 里、授权过滤器负责判断当前用户是否允许访问这个 URL和ExceptionTranslationFilter负责捕获授权过程中抛出的异常再做转发处理。授权过滤器的判断逻辑本质上就是拿“当前用户的权限集合”和“这个资源要求的权限”做比对。比如PreAuthorizationFilter会调用AuthorizationManager里面有一个核心的check()方法返回一个AuthorizationDecision也就是“通过”还是“拒绝”。如果决策结果是拒绝授权过滤器就会直接抛出AccessDeniedException。这个过程就像拿着工牌去刷门禁系统先确认你是不是公司员工认证再确认你有没有进这间办公室的权限授权。两关都过了才放行第二关不过就会报访问被拒绝。这里有个容易被忽略的点ExceptionTranslationFilter在捕获到AccessDeniedException之后会先看一眼当前用户是不是已经登录了。如果确实没有登录或者是一个匿名用户它就会调用AuthenticationEntryPoint一般是返回 401 状态码或者重定向到登录页。只有当用户已经登录但权限不够时才会真正走到AccessDeniedHandler返回 403 状态码或者一个自定义的拒绝页面。所以很多场景下你看到的 403“不允许访问”其实就是这个过滤器链里的最后一棒问题往往不在这个异常本身而在上一层的用户权限列表或者资源配置。1.3 版本差异为什么报错包名可能是org.springframework.security.access但新项目用的是另一个包关于这个异常有个特别容易让项目升级时踩坑的点不同版本的 Spring SecurityAccessDeniedException的包路径是不一样的。Spring Security 5.x 时代异常类在org.springframework.security.access.AccessDeniedException下面这也是很多老项目里 import 语句的写法。从 Spring Security 6.0 开始这个异常类被重构到了org.springframework.security.authorization.AccessDeniedException同时新的包路径下还有一个父类AuthorizationDeniedException。如果你把一个 Spring Security 5 的项目升级到 6最常见的第一道坎就是编译报错找不到org.springframework.security.access.AccessDeniedException。我见过不少团队升级时图省事直接全局搜索替换包名结果把原本用于自定义异常处理、或者在某些业务代码里 catch 这个异常的地方也一起改了后续排查问题时发现日志里抛的还是旧包的类而代码 catch 的是新包的类导致异常没有被正确处理最终又漏回页面上变成“不允许访问”。我的建议是升级之前先整理一下项目中所有直接使用AccessDeniedException的代码位置分清哪些是 Spring Security 内部自动抛出的哪些是你自己 catch 后做业务处理的再决定怎么迁移。如果你现在还报的是org.springframework.security.access这个包名基本可以断定项目用的还是 Spring Security 5.x 的环境排查时就不要按 6.x 的源码去看避免越绕越远。2. 新手最容易踩的三个配置坑匹配顺序、角色前缀、注解开关2.1 过滤器链的匹配顺序比你想的更“先到先得”在 Spring Security 的 Java Config 配置里最常见的写法是HttpSecurity上挂一串规则比如http.authorizeHttpRequests(auth - auth .requestMatchers(/api/public/**).permitAll() .requestMatchers(/api/admin/**).hasRole(ADMIN) .anyRequest().authenticated() );这里有个初学者容易忽略的规则过滤器链是从上往下匹配的先匹配到哪条规则就用哪条后面的规则不会再看。也就是说如果你把anyRequest().authenticated()写在了最上面那么下面所有.permitAll()和.hasRole()全都白写因为每个请求到第一行就已经被anyRequest()接住了。这个点看着简单但实际项目里经常有人把permitAll写在最后导致本应放行的静态资源比如/assets/**、/favicon.ico全部被拦页面上就会出现一堆访问被拒绝的报错。我自己一般会强制自己遵守一个顺序先放行完全公开的资源再放行需要特定角色的接口最后才是兜底的anyRequest()。这就像写路由时要求“具体规则放前面兜底规则放最后”一样逻辑清晰不说也方便后面的人维护。如果哪天你发现某个接口明明配置了permitAll却还是 403先去检查是不是匹配顺序的问题八成能在前几行规则里找到原因。2.2 角色名的前缀坑hasRole和hasAuthority是两回事第二个高发问题出在角色名的前缀上。Spring Security 里有两个特别像的方法hasRole(ADMIN)和hasAuthority(ROLE_ADMIN)。实际上hasRole(ADMIN)内部会帮你自动拼上ROLE_前缀也就是它等价于hasAuthority(ROLE_ADMIN)。问题就来了如果你的用户权限列表里存放的角色名是ADMIN而不是ROLE_ADMIN那么你用hasRole(ADMIN)是能通过的因为框架会拼出ROLE_ADMIN去比对。但如果你的数据库里存的是admin小写或者存的是ROLE_ADMIN而配置里用了hasAuthority(ADMIN)就永远比对不上因为hasAuthority是原封不动地拿字符串去比。最终表现就是用户明明有管理员角色访问该角色专属接口的时候照样抛AccessDeniedException。这种问题非常迷惑因为看日志、看数据库都觉得对就差一个字符的差。排查的时候最快的办法是在代码里打印一下当前用户的Authentication.getAuthorities()看一眼里面的值到底长什么样再比对配置里的写法。我做过一个统计在各种“不允许访问”的案例里因为角色前缀或大小写不一致引起的问题占了差不多三成。这不是小事。2.3 方法级安全注解没开启PreAuthorize直接当摆设还有一类场景和 URL 级授权无关而是发生在 Service 层或者 Controller 层的PreAuthorize注解上。这个注解本身不会自动生效你必须在配置类里显式开启方法级安全。Spring Security 5.x 用的是EnableGlobalMethodSecurity(prePostEnabled true)Spring Security 6.x 则换成了EnableMethodSecurity。如果你的项目没加这个注解那么你的 Service 方法上写的PreAuthorize(hasRole(ADMIN))就完全没有作用。注意这里出问题的方式有两种一种是“该拦的没拦”比如普通用户也能访问管理员接口另一种则是“不该拦的拦了”比如说你开了方法级安全但 SecurityContext 里拿不到正确的权限信息所有带有PreAuthorize的方法全部报访问被拒绝。后面这种情况在微服务环境下尤其常见因为有些服务做了登录状态的互相信任但并没有把用户角色信息完整传递过去。建议所有用方法级安全注解的团队在项目启动的时候先自查一下有没有加EnableMethodSecurity并且在测试环境写一个最简单的PreAuthorize(hasRole(TEMP))接口用不同角色的账号各调一次确认注解真的生效了再继续搞复杂逻辑。这一步能帮你把“配置问题”和“业务问题”快速分开。2.4 未登录用户也可能报AccessDeniedException别被表面现象骗了还有一个让人头疼的情况明明没有登录按理说应该先跳登录页结果控制台报的也是AccessDeniedException。这是因为 Spring Security 默认的授权过滤器在判断权限不通过时会先看当前用户有没有完整认证。如果项目里用了匿名过滤器那么每个未登录用户在安全框架里也会有一个“匿名用户”的身份它的权限列表里通常只有一个ROLE_ANONYMOUS。当这个匿名用户请求一个需要ROLE_USER的接口时授权过滤器推断出权限不足照样抛出访问被拒绝的异常。这个时候ExceptionTranslationFilter会做一个判断如果当前用户是匿名用户就转入AuthenticationEntryPoint让你去登录但如果你的代码在某个try-catch里自己吞掉了这个异常或者拦截器配置有问题就可能把 401 和 403 混在一起最后展示成“不允许访问”。所以看到AccessDeniedException一定要先分清当前用户是“已登录无权限”还是“未登录”。最简单的分辨方法是看SecurityContextHolder.getContext().getAuthentication()是不是AnonymousAuthenticationToken。如果是说明你面对的是未登录访问受保护资源的问题重点应该去查登录拦截和路由配置而不是去查角色权限。3. 一个真实案例从“不允许访问”到放行的完整排查3.1 场景还原一个订单后台管理系统的 403 问题去年下半年我一个朋友负责的订单后台管理系统出了这么一个问题前端页面访问/api/orders/list接口时页面直接弹“不允许访问”但系统里同一个管理员账号在另一台机器上却是正常的。开发环境、测试环境都试了有时好有时坏非常有迷惑性。我把当时的排查思路完整记录了下来可以作为这类问题的一个参考范例。项目基本情况是Spring Boot 2.7 Spring Security 5.8使用 JWT Token 做认证用户服务会把用户的角色列表放在 Redis 里订单系统从 Redis 拉取权限后在本地生成UsernamePasswordAuthenticationToken并放进SecurityContext。接口层没有写PreAuthorize只是在SecurityConfig里配置了requestMatchers和anyRequest().authenticated()。也就是说理论上只要用户登录了就能访问并没有细到角色级别。照理说管理员登录后应该有权限为什么还是出现“不允许访问”3.2 第一步先看异常栈而不是急着改代码我做的第一个动作是让他把后端控制的完整异常栈发过来。日志里确实是org.springframework.security.access.AccessDeniedException: Access is denied紧接着是ExceptionTranslationFilter的调用链。这里面有一个关键信息异常是在过滤器层面抛出来的不是业务代码抛出来的。这意味着我们需要查的是 Spring Security 过滤器链里的环节而不是某个 Service 或 Mapper。由于是“有时好有时坏”我第一时间排除了静态配置写错的可能性因为如果是配置写错了那应该是所有请求、所有用户都 403不会出现随机性。接下来重点怀疑两个地方一是用户信息和权限的加载过程不稳定二是多台机器之间 Redis 数据不一致或者 Token 解析出来的权限列表有差异。3.3 第二步打印当前用户的Authentication对象定位异常类型之后我们加了一段临时调试代码在ExceptionTranslationFilter之前的位置记录当前请求对应的用户信息Authentication authentication SecurityContextHolder.getContext().getAuthentication(); if (authentication ! null) { logger.info(当前用户: {}, 权限列表: {}, authentication.getName(), authentication.getAuthorities()); } else { logger.info(当前用户: null); }加了这个日志后很快就发现了问题报 403 的请求里SecurityContext中保存的用户信息是空的authentication直接就是null。而正常的请求里用户信息和权限列表都在。这就说明问题不在授权配置而在认证阶段——某些请求进来时Token 没有被成功解析导致后面的授权过滤器“不认识”你把你当成匿名用户处理了。顺着这个线索再查发现这段系统里用了一个比较老的 JWT 解析工具它对 Token 的过期时间处理有一个隐藏 bug当请求进来的时刻恰好在 Token 过期时间前后时解析会随机失败。最终修复方式是换成统一的 JWT 解析组件加了过期时间的容错逻辑并且在后端增加了一个“Token 解析失败则返回 401 而不是 403”的兜底处理。处理完之后这个“有时好有时坏”的 403 就再也没复现过。3.4 第三步回到配置检查拦截规则是否把所有接口都兜住了在这个案例里还有一个衍生问题为什么 Token 解析失败时抛出的是AccessDeniedException而不是认证异常因为ExceptionTranslationFilter在处理时发现SecurityContext里没有认证信息走的是匿名用户分支而授权过滤器对匿名的requestMatchers配置是anyRequest().authenticated()于是直接抛访问拒绝。最后我们调整了认证过滤器让它一旦发现 Token 解析失败就立刻抛出一个认证异常这样 401 和 403 才能正确区分前端也能根据状态码做对应的跳转提示而不是永远收到一个模糊的“不允许访问”。3.5 修复后的配置代码为了把这类问题尽早暴露我在项目里加了这样一段配置http.exceptionHandling(ex - ex .authenticationEntryPoint((request, response, authException) - { response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\登录已失效请重新登录\}); }) .accessDeniedHandler((request, response, accessDeniedException) - { response.setStatus(403); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\message\:\没有权限访问该资源\}); }) );这两段代码表面上是给前端返回更友好的提示实际上更重要的是让我们能区分异常来源如果页面提示“登录已失效”说明是认证阶段出了问题如果提示“没有权限访问该资源”说明是授权阶段出了问题。排查范围一下子缩小了一半。这个思考路径比单纯盯着异常本身转圈要高效得多。4. 不要把异常直接抛给用户自定义 AccessDeniedHandler 的细节4.1RestControllerAdvice为什么抓不到它很多团队在前后端分离项目里会写一个全局异常处理器捕获各种业务异常然后统一返回 JSON。但第一次接触 Spring Security 的人通常会踩一个坑明明全局异常处理器里写了ExceptionHandler(AccessDeniedException.class)却始终不生效页面还是照样显示“不允许访问”。原因在于过滤器中抛出的异常发生在请求进入DispatcherServlet之前RestControllerAdvice只处理进入了 Spring MVC 分发流程后抛出的异常它根本看不到过滤器里发生了什么。你写一万个ExceptionHandler都拦截不到。正确的做法是在HttpSecurity的exceptionHandling里指定AccessDeniedHandler和AuthenticationEntryPoint。这两个 Web Security 层面的处理器才是真正“接住”异常的地方。很多教程会提到自定义AccessDeniedHandler但很少有人把“为什么不用RestControllerAdvice”讲清楚实际上这才是很多人的困惑源头。4.2 自定义AccessDeniedHandler返回统一 JSON 的正确姿势自定义处理器的核心接口就一个方法public class JsonAccessDeniedHandler implements AccessDeniedHandler { Override public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:403,\message\:\您没有权限进行此操作\}); } }然后在配置里挂上去http.exceptionHandling(ex - ex.accessDeniedHandler(new JsonAccessDeniedHandler()));这段代码本身并不复杂但有三个细节值得注意。第一response.setStatus一定要设置否则前端拿到的是 HTTP 200 但 body 里告诉你没权限这会让前端同事非常困惑第二响应 JSON 的字段最好和你的全局异常格式保持一致比如都叫code和message避免前端要写两套解析逻辑第三response.getWriter().write()之后不用调用flush和close交给容器处理就行但如果你用了一些包装 response 的过滤器也有可能遇到流被关闭的问题到时候再手动flush也不迟。4.3 自定义处理器不生效的几种排查方向写好了AccessDeniedHandler重启项目后发现还是不生效这种情况我也遇到过好几次。最常见的原因有三个。第一个原因是配置被覆盖。如果你的项目里加了多个SecurityFilterChain的Bean一些老代码里还可能存在一个“默认配置”的WebSecurityConfigurerAdapterSpring Security 5 时代遗留两个配置起了冲突最终生效的是没有挂自定义 handler 的那条链路。排查方法是在项目里搜一下SecurityFilterChain和WebSecurityConfigurerAdapter的类看有没有重复定义。第二个原因是异常根本没有被ExceptionTranslationFilter捕获。比如某些过滤器在你自定义的授权逻辑中直接 try-catch 了异常然后自己处理掉了那么你配置的accessDeniedHandler自然不会被调用。还有种情况是使用了早于 Spring Security 5.6 的版本部分行为的细节和后来版本不一致。第三个原因可能出在拦截器顺序上。如果你的AccessDeniedHandler内部返回了自定义 JSON但某个后续过滤器在输出时又添加了页头或者改写了响应体也可能导致最终的响应内容不是预期内容。遇到这种情况最简单的验证办法是在浏览器开发者工具里看“Network”面板中该请求的状态码如果状态码是 403说明 handler 生效了失败点在后面对响应体的处理如果状态码是 200说明 handler 压根没有被调用应该回到配置去查。5. 工作中遇到的 AccessDeniedException 变体问题5.1PreAuthorize在类内部方法调用时不生效这是一个特别隐蔽的问题。你在同一个类里写了一个方法上面标了PreAuthorize(hasRole(ADMIN))然后在同一个类的另一个方法里直接调用它比如this.someMethod()结果发现权限校验完全没生效。原因在于 Spring Security 的方法级安全是基于 AOP 代理实现的只有通过代理对象调用方法时切面逻辑才会执行。如果你在类内部用this直接调用实际调用的是原始对象的方法注解自然被绕过了。解决办法是注入自己或者通过ApplicationContext.getBean()获取代理对象后再调用。我在实际项目中见过不少因为这种问题导致的“看似配置了权限实际上任何人都能访问管理员方法”的情况比 403 更危险因为表面一切正常实际上权限控制完全失灵。建议团队在写权限敏感代码时把这类方法拆到独立的 Service 类中避免同类内调用。5.2 异步线程里的SecurityContext丢失当你的业务代码里使用Async、CompletableFuture或者手动创建线程去处理任务时新线程里通常会找不到原来的用户信息。因为SecurityContext默认绑定在创建它的线程上线程池中的新线程并不会自动继承这份上下文。如果异步任务需要读取当前用户信息或者再次做权限校验就会出现AccessDeniedException因为那个线程里的authentication对象是空的。解决办法有两种一种是在提交任务时手动把SecurityContext传进去SecurityContext context SecurityContextHolder.getContext(); CompletableFuture.runAsync(() - { SecurityContextHolder.setContext(context); // 你的业务逻辑 });另一种是使用 Spring Security 提供的DelegatingSecurityContextCallable或DelegatingSecurityContextRunnable包装任务。这两种方式都能把 SecurityContext 传递到子线程。这个坑在微服务拆分的异步链路里很常见排查的时候往往要找很久才能发现是线程切换的问题。5.3 JWT 里的权限 claim 没被解析出来如果你的项目是用 JWT 作为认证凭证服务器端从 Token 中解析用户权限时经常会出现“权限列表为空”的问题。常见原因是 JWT 的荷载Payload里权限字段的 key 和解析代码里使用的 key 不一致。比如你在发行 Token 时写入的是roles而后端解析代码里读的是authorities那最终生成的权限列表肯定是空的。此时访问任何受保护接口都会因为用户没有任何权限而触发AccessDeniedException。排查方法非常简单把 Token 在网站或者本地的调试工具里解出来看一下payload里的字段名然后去对比后端解析的代码。另外还有一个容易忽视的点就是 JWT 里存的角色名到底带不带ROLE_前缀。如果你在生成 Token 时直接把角色名单交给了setClaims而后端用hasRole(ADMIN)校验注意hasRole自己会加前缀所以 Token 里存的就必须是ADMIN而不能是ROLE_ADMIN否则会变成边对边错。这个细节稍不留意就会浪费半天时间。5.4 从 Spring Security 5 升级到 6 之后出现的全新 403Spring Security 6 的默认行为比 5 更严格比如默认开启了 CSRF 防护默认要求所有请求都要走认证链不少从 5 升上来的项目会突然出现大量“不允许访问”。原因往往不是业务逻辑变了而是框架默认行为变了。典型的例子是升级到 6 之后老项目里WebSecurityConfigurerAdapter的写法被废弃配置需要迁移到SecurityFilterChain模式同时securityMatcher的匹配逻辑、authorizeRequests之类的旧方法名都被替换成了新写法。遇到这种情况我的建议是不要急着改业务配置先翻一下官方从 5 到 6 的迁移文档重点看三点一是HttpSecurity配置的写法变化二是AccessDeniedException包名变化三是默认开启的 CSRF 和 CORS 策略对现有请求的影响。很多时候升级完 403其实是新版本默认把某些接口当成了受保护接口而老配置还没有迁移过来。理解了这些差异再排查远比一条一条试错要快。6. 一张速查表快速定位 AccessDeniedException 的常见原因日常工作里我排查AccessDeniedException基本已经形成了一套肌肉记忆。下面这张表是我结合自己的实际经历和团队其他同学的反馈整理出来的高频原因和对应的解决方向遇到问题可以直接拿去对照现象特征常见原因优先排查方向已登录用户访问某些接口 403日志有AccessDeniedException用户权限列表中缺少资源要求的角色打印authentication.getAuthorities()对比配置里的hasRole/hasAuthority未登录用户访问受保护接口却显示“不允许访问”匿名用户被授权过滤器拒绝401/403 未区分检查SecurityContext是否是AnonymousAuthenticationToken并在exceptionHandling里配置AuthenticationEntryPoint配置了PreAuthorize但完全不生效方法级安全注解未开启检查是否加了EnableMethodSecurity或EnableGlobalMethodSecurity(prePostEnabled true)同一个请求时好时坏偶尔 403Token 解析异常或权限数据加载不稳定检查 JWT 解析逻辑、Redis/Session 中角色数据是否完整普通角色无法访问管理员接口但回显并不是 404hasRole与hasAuthority的前缀/大小写不匹配打印授权列表确认是否为ROLE_ADMINvsADMIN自定义AccessDeniedHandler没生效SecurityFilterChain被重复配置或异常被提前捕获搜项目里的所有SecurityFilterChainBean确认是否有多套配置异步线程里抛出的权限异常SecurityContext未传递到子线程使用DelegatingSecurityContextRunnable或手动设置SecurityContext从 Servlet 3.1 迁移或者从老版本升级后出现 403框架版本行为变化旧配置不兼容查阅对应版本的迁移指南重点看默认 CSRF、匹配规则和包名迁移这张表不是万能的但它能帮你把问题快速归到“配置问题”“数据问题”“环境问题”三个大类里。在团队内部排查时我一般还会让大家把这张表和“最后一步操作”关联起来谁改了什么配置、谁换了什么依赖、谁调整了哪个接口的权限要求往往一对比就能立刻看到问题本质。回到文章开头说的那个现象看到org.springframework.security.access.AccessDeniedException先别急着改代码。这个异常是 Spring Security 用“主动拒绝”的方式告诉你某个人、某个 token、某个请求在授权链路上的身份或者权限信息和目标资源对不上。至于是人没登录、角色没配、前缀写错、注解没开、上下文丢失还是版本迁移遗留的问题只需要按上面的几个方向逐一排查。我自己的排查习惯永远是先打印Authentication看当前用户到底是“谁”再对比配置里期待的是“谁”然后才开始改代码。这听起来像一个笨办法却能在十有八九的场景里一击命中。最后再分享一个小习惯在开发环境里我会把这些异常日志的级别调到 DEBUG把拦截器和授权链路上每一步决策结果都打出来这样即使前端什么都看不出来后端也能清清楚楚地看到是哪个环节把请求拒掉了。排查这类问题最忌讳的就是一边看代码一边猜与其猜不如花两分钟把现场信息打全方向对了解决起来就快多了。