What this usually means
Spring Security's Access Denied mechanism has rejected a request. This is almost never a network or server configuration issue—it's a mismatch between the security rules you think are applied and the rules actually in place. The root cause often lies in annotation misconfiguration (e.g., @PreAuthorize on the wrong method), role name mismatches (ROLE_ prefix), or a corrupted filter chain where your custom filter runs too early or too late.
The first ten minutes — establish facts before touching code.
- 1Enable debug logging: add 'logging.level.org.springframework.security=DEBUG' to application.properties
- 2Send a request with curl -v or Postman, and check the server logs for 'Access is denied' and 'Authorization failure' lines
- 3Look for the 'SecurityContextHolder' content in logs to verify the authentication object
- 4Check if the user has the correct GrantedAuthority (e.g., ROLE_ADMIN, not just ADMIN)
- 5Verify that @EnableGlobalMethodSecurity or similar is present if using method-level annotations
- 6Test with a simple permitAll rule to isolate if the issue is with authorization or authentication
The specific files, logs, configs, and dashboards that usually own this bug.
- searchapplication.properties / application.yml for debug logging settings
- searchSecurityConfig class (or any @Configuration with @EnableWebSecurity)
- searchController methods annotated with @PreAuthorize, @Secured, or @RolesAllowed
- searchUserDetailsService implementation to see what authorities are returned
- searchCustom filters that manipulate SecurityContextHolder
- searchRole hierarchy configuration (if using @RoleHierarchy)
- searchAccessDeniedHandler implementation (if custom)
Practical causes, not theory. These are the things you will actually find.
- warningMissing or incorrect ROLE_ prefix in @PreAuthorize (e.g., hasRole('ADMIN') vs hasAuthority('ROLE_ADMIN'))
- warningMethod-level security annotation on a private method (Spring ignores them)
- warning@EnableGlobalMethodSecurity missing or misconfigured (proxy-target-class, mode)
- warningUser not having the required authority due to a bug in UserDetailsService
- warningFilter chain order causing a filter to short-circuit before security rules are evaluated
- warningRole hierarchy not applied because @RoleHierarchy not injected into security config
Concrete fix directions. Pick the one that matches your root cause.
- buildStandardize on hasAuthority() or hasRole() and ensure the ROLE_ prefix is consistent
- buildAdd @EnableGlobalMethodSecurity(prePostEnabled = true) to your security config
- buildRemove @PreAuthorize from private methods or make them public
- buildAdd a custom AccessDeniedHandler to log the exact reason with more detail
- buildUse RoleHierarchyImpl and wire it into the AccessDecisionManager or HttpSecurity
- buildReorder filters in the security chain using addFilterBefore/After with correct positions
A fix you cannot prove is a guess. Close the loop.
- verifiedSend a request as the affected user and confirm 200 instead of 403
- verifiedCheck logs for 'Authorized' line instead of 'Access is denied'
- verifiedWrite a Spring MockMvc test with @WithMockUser(roles="ADMIN") and assert status is 200
- verifiedUse Actuator /actuator/httptrace to see the request and response (if enabled)
- verifiedVerify in debugger that the authentication object contains the expected authorities
Things that make this bug worse or harder to find.
- warningAdding @EnableWebSecurity multiple times (causes duplicate filter chains)
- warningForgetting that @PreAuthorize matches exact authority strings (case-sensitive)
- warningBlindly disabling CSRF without understanding the trade-offs
- warningUsing @Secured instead of @PreAuthorize and mixing them
- warningAssuming the user's role is correct without printing it in logs
The Vanishing /api/admin Endpoint: A Role Prefix Mystery
Timeline
- 09:30Deploy v2.3 with new /api/admin/stats endpoint
- 10:15Team lead reports 403 on /api/admin/stats, other admin endpoints work
- 10:20Check logs: 'Access is denied' with no details
- 10:25Enable DEBUG for org.springframework.security
- 10:30Resend request: log shows user has ROLE_ADMIN, but required authority is ROLE_ADMIN? Wait, it says 'ADMIN' without prefix
- 10:35Find @PreAuthorize("hasRole('ADMIN')") on the new method
- 10:40Change to hasRole('ROLE_ADMIN') and re-deploy
- 10:45Test – 200 OK. Root cause: hasRole() adds ROLE_ prefix, but I passed the full name anyway causing double prefix ROLE_ROLE_ADMIN? No, hasRole('ADMIN') becomes ROLE_ADMIN, but the user had ROLE_ADMIN? Wait, read the log again: user had authority 'ROLE_ADMIN', but the required was 'ADMIN' – that means hasRole('ADMIN') checks for 'ADMIN' not 'ROLE_ADMIN'? Actually hasRole adds ROLE_ prefix, but the authority stored in UserDetails was 'ROLE_ADMIN', so it should match. Let me re-check: the log said 'Required authority: ADMIN'? That's odd.
- 10:50Check actual log: 'AuthorizationFailure: Access is denied' with no details. After enabling TRACE, see that the voter rejected because the required authority was 'ADMIN' (without prefix) but the user had 'ROLE_ADMIN'. That means hasRole('ADMIN') should have added prefix? In our code, we used hasRole('ROLE_ADMIN') inadvertently, which becomes 'ROLE_ROLE_ADMIN'. The correct fix: use hasRole('ADMIN') or hasAuthority('ROLE_ADMIN').
I had just deployed a new endpoint for admin stats. The team lead immediately got 403. I checked logs – nothing. I enabled DEBUG and saw that the user had ROLE_ADMIN but the authorization decision was failing. The log said 'Required authority: ADMIN' – what? I used @PreAuthorize("hasRole('ADMIN')") which should add ROLE_ prefix, resulting in ROLE_ADMIN. That should match. But the log showed 'ADMIN' as required.
After re-reading the log more carefully, I realized the log line was from RoleVoter, and it printed the required attribute as 'ADMIN' because RoleVoter expects the role name without prefix? No, RoleVoter internally uses the role name as stored. Actually, the issue was that I had mistakenly written @PreAuthorize("hasRole('ROLE_ADMIN')") – which adds another ROLE_ prefix, making the required authority 'ROLE_ROLE_ADMIN'. But the log said 'ADMIN'? That doesn't add up.
I finally printed the authorities and the expression. The truth: the user's authorities were ['ROLE_ADMIN', 'ROLE_USER']. The method annotation was @PreAuthorize("hasRole('ADMIN')") which evaluates to hasRole('ADMIN') -> hasAuthority('ROLE_ADMIN')? No, hasRole('ADMIN') calls hasAuthority('ROLE_ADMIN')? Actually Spring Security's hasRole adds the prefix. So it becomes hasAuthority('ROLE_ADMIN'). That should match. But the log still said 'Required authority: ADMIN'. I traced through the debugger and found that the RoleVoter was not being used because we had a custom AccessDecisionManager that used a different voter. That voter was comparing the string 'ADMIN' directly against the authority strings, which didn't match 'ROLE_ADMIN'. The fix was to change the voter to add the ROLE_ prefix or use hasAuthority('ROLE_ADMIN') in the annotation.
Root cause
Custom AccessDecisionManager with a voter that did not apply the ROLE_ prefix, while the annotation used hasRole('ADMIN') which expects the prefix to be added by the voter. Mismatch between voter and annotation logic.
The fix
Changed the annotation to @PreAuthorize("hasAuthority('ROLE_ADMIN')") to explicitly match the stored authority, and updated the custom voter to handle the prefix consistently.
The lesson
Never assume that hasRole and custom voters handle the ROLE_ prefix the same way. Explicitly use hasAuthority when you control the authority strings. Always enable security debug logging to see the exact required vs granted authorities.
Set logging.level.org.springframework.security=DEBUG in application.properties. The key log lines include: 'SecurityContextHolder contents: ...' showing the current authentication object; 'Authorization failure' with the exception; and 'Voter: ... voted to deny' showing which voter rejected. For method security, look for 'PreInvocationAuthorizationAdvice' logs that show the expression and the authentication.
If TRACE level is enabled, you'll see the exact required authorities and the voter decisions. Use grep to filter: grep "Access is denied" or "Authorization failure". This often reveals that the required authority string does not match the user's granted authorities.
The @PreAuthorize("hasRole('ADMIN')") annotation uses the expression handler, which internally calls hasRole('ADMIN') -> hasAuthority('ROLE_ADMIN'). This works if the user's authority is stored as 'ROLE_ADMIN'. But if you use @PreAuthorize("hasAuthority('ADMIN')") without the prefix, it will not match a user with ROLE_ADMIN. Conversely, if you use hasRole('ROLE_ADMIN'), it becomes hasAuthority('ROLE_ROLE_ADMIN'), which almost certainly fails.
The fix: standardize your authorities. I recommend always storing authorities without the ROLE_ prefix (e.g., 'ADMIN') and then use hasAuthority('ADMIN') or hasRole('ADMIN') consistently. But if you use the default UserDetailsService that returns GrantedAuthority objects with ROLE_ prefix (like SimpleGrantedAuthority), then use hasRole('ADMIN') because it adds the prefix. The critical point: check the actual authority strings in your UserDetails implementation.
If you implement a custom AccessDecisionManager or voter, you must ensure that the voter's logic aligns with the attribute passed by FilterSecurityInterceptor or the expression handler. The attribute string comes from the security configuration. For http.authorizeRequests(), the attribute is the role name without prefix (if using hasRole), and the voter is expected to add the prefix. For method security with @PreAuthorize, the attribute is the result of the expression evaluation – it's already a full authority string (with prefix if hasRole was used).
I've seen teams write a custom voter that does a simple string comparison against the authority, forgetting that http.authorizeRequests() passes the role name without prefix. The fix is to either let the voter add the prefix or use hasAuthority in the configuration. Always test with a simple unit test that prints the attribute value.
Write a Spring Boot test using @WebMvcTest and SecurityMockMvcRequestPostProcessors. For example: mockMvc.perform(get("/api/admin").with(user("admin").roles("ADMIN"))).andExpect(status().isOk()). The roles() method adds the ROLE_ prefix automatically. If your test fails with 403, it reveals the mismatch early.
You can also use @WithMockUser(username="admin", roles={"ADMIN"}) at class level. If the test passes but production fails, the issue is likely in the UserDetailsService or authentication filter. If the test fails, your method security annotation is wrong.
Frequently asked questions
Why does Spring Security return 403 instead of 401?
401 Unauthorized means the user is not authenticated (no valid credentials). 403 Forbidden means the user is authenticated but lacks the required permissions. Check if the request includes a valid Authorization header or session cookie. If it does, the issue is authorization; if not, it's authentication.
How do I see the exact required authority in the logs?
Enable DEBUG logging for org.springframework.security. Look for lines like 'FilterSecurityInterceptor: Secure object invocation: ...' and 'AccessDecisionManager: Access is denied'. For more detail, enable TRACE logging to see 'RoleVoter: ... attribute = ADMIN' and 'User has authority ROLE_ADMIN'. Compare the attribute with the user's authorities.
Why does @PreAuthorize work on some methods but not others?
Check that the method is public and called through a Spring proxy. @PreAuthorize is applied via AOP; if the method is called internally (this.method()) or is private, the annotation is ignored. Also ensure @EnableGlobalMethodSecurity is on a configuration class.
Can a wrong filter order cause a 403?
Yes. If a custom filter runs before the SecurityContextPersistenceFilter, it may not have access to the authentication. Or if a filter calls SecurityContextHolder.clearContext() prematurely, subsequent filters see an unauthenticated user. Check your filter chain ordering with addFilterBefore and addFilterAfter.
What is the difference between hasRole and hasAuthority?
hasRole('ADMIN') automatically adds the ROLE_ prefix, becoming hasAuthority('ROLE_ADMIN'). hasAuthority does not add any prefix. Use hasRole when your authorities are stored with ROLE_ prefix, and hasAuthority when they are stored without. Mixing them leads to 403.