A 403 Forbidden from Spring Security is one of the most frustrating errors in the Java ecosystem. Authentication passed, the endpoint exists, and the user’s credentials look correct — yet the request gets rejected with Access is denied. The cause could be any one of a half-dozen distinct issues. This guide walks through the most common ones systematically.
Quick Answer: Most Spring Security 403 errors fall into one of these buckets:
- CSRF token missing — POST/PUT/DELETE requests blocked by default CSRF protection
- Role prefix mismatch — using
hasRole("ADMIN")but the authority is stored as"ADMIN"not"ROLE_ADMIN" - Wrong HTTP method matcher — URL is permitted but the method isn’t
- Method-level security not enabled —
@PreAuthorizeannotations silently ignored - Authority vs Role confusion —
hasRolevshasAuthoritysemantics differ - Filter chain ordering — a second
SecurityFilterChainbean overrides your rules
How Spring Security Decides to Return 403
Before debugging specific causes, it’s worth understanding the two distinct phases where a 403 can originate:
Phase 1 — Authentication (401 territory): Is this user who they claim to be? If not, Spring typically returns 401 (Unauthorized), not 403.
Phase 2 — Authorization (403 territory): Does this authenticated user have permission to do this? A failure here gives you 403 Forbidden.
The AccessDeniedException is thrown by AccessDecisionManager (or the newer AuthorizationManager in Spring Security 6+) and caught by ExceptionTranslationFilter, which delegates to AccessDeniedHandler — by default returning a plain 403 response.
Knowing this distinction matters: if you see a 403 but you expected a 401, your user is being authenticated (or is anonymous but treated as authenticated), and the authorization rules are what’s rejecting them.
Step 1: Enable Debug Logging First
Don’t guess. Turn on Spring Security’s debug output before doing anything else:
# application.yml
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.web.FilterChainProxy: DEBUG
With this enabled, every request will log which filters ran, which security chain matched, and exactly where the access decision was made. Look for lines like:
DEBUG FilterSecurityInterceptor - Previously Authenticated: ...
DEBUG AffirmativeBased - Voter: org.springframework.security.access.vote.RoleVoter, returned: -1
DEBUG ExceptionTranslationFilter - Access is denied (user is anonymous)
The voter result (-1 = deny, 1 = allow, 0 = abstain) tells you immediately whether the problem is a missing role, an anonymous user, or something else entirely.
Cause #1: CSRF Token Missing
The symptom: All your GET requests work fine, but POST, PUT, PATCH, and DELETE return 403.
Spring Security enables CSRF protection by default. Every state-changing request must include a valid CSRF token. If you’re calling a REST API from a JavaScript client, a mobile app, or a tool like Postman without configuring CSRF, every mutating request gets rejected.
// This configuration silently blocks all POST/PUT/DELETE from API clients:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
)
.httpBasic(Customizer.withDefaults());
// CSRF is ON by default — no token = 403
return http.build();
}
}
Fix for stateless REST APIs: Disable CSRF when using token-based authentication (JWT, API keys). CSRF attacks require cookie-based auth to be a threat, so stateless APIs don’t need it.
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Safe for stateless JWT APIs
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
Fix for server-rendered apps (Thymeleaf, etc.): Keep CSRF enabled but make sure your forms include the token:
<!-- Thymeleaf automatically injects the CSRF token -->
<form th:action="@{/submit}" method="post">
<!-- Spring Security's Thymeleaf dialect adds hidden input automatically -->
<button type="submit">Submit</button>
</form>
If you’re using a JavaScript frontend with session cookies, fetch the token from the meta tag or use the CookieCsrfTokenRepository to expose it in a cookie that your JS can read.
Cause #2: Role Prefix Mismatch (hasRole vs hasAuthority)
This one catches almost everyone. Spring Security’s hasRole() automatically prepends "ROLE_" to whatever string you pass. So hasRole("ADMIN") actually checks for the authority "ROLE_ADMIN".
// ❌ This returns 403 if your UserDetails stores "ADMIN" (not "ROLE_ADMIN"):
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
);
// And the UserDetails looks like:
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ADMIN")); // Missing "ROLE_" prefix!
}
You have two ways to fix this:
Option A — Add the prefix in your UserDetails:
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return List.of(new SimpleGrantedAuthority("ROLE_ADMIN")); // ✅ Correct prefix
}
Option B — Use hasAuthority() instead of hasRole():
// hasAuthority does NOT add any prefix — matches the string exactly
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasAuthority("ADMIN") // ✅ Matches "ADMIN" directly
);
Pick one approach and be consistent across your whole app. Mixing hasRole and hasAuthority with inconsistent authority naming is a common source of subtle bugs.
Cause #3: Request Matcher Order / Wrong HTTP Method
Spring Security evaluates requestMatchers rules in order, stopping at the first match. If a broad permissive rule comes before a specific restrictive one, or if you’re matching the URL but not the HTTP method, you’ll get unexpected results — including 403s.
// ❌ Problematic: the broad anyRequest().authenticated() won't be the issue,
// but method-specific matchers can bite you:
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/users/**").permitAll()
.requestMatchers("/api/users/**").authenticated() // POST, PUT, DELETE hit this
.anyRequest().authenticated()
);
If you intend to allow GET but restrict mutations, that’s correct. But if you’re hitting 403 on a GET request to /api/users/123, double-check that your matcher actually covers the path you’re requesting — trailing slashes, path variables, and wildcard patterns all behave differently.
// ✅ Explicit HTTP method matching
http.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/users/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/users/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.PUT, "/api/users/**").hasRole("ADMIN")
.requestMatchers(HttpMethod.DELETE, "/api/users/**").hasRole("ADMIN")
.anyRequest().authenticated()
);
A useful debugging trick: temporarily change a rule to permitAll() to confirm the URL is matching. If the 403 goes away, you know the matcher is correct and the issue is the authority check.
Cause #4: @PreAuthorize Silently Ignored
You’ve added @PreAuthorize("hasRole('ADMIN')") to a service method, but it seems to be doing nothing — the method either always allows or always blocks. The most likely cause: method security isn’t enabled.
// ❌ @PreAuthorize does nothing without this annotation:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
// Missing @EnableMethodSecurity
}
// ✅ Enable method-level security:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // Required for @PreAuthorize, @PostAuthorize, @Secured
public class SecurityConfig {
// ...
}
Note: In older Spring Security versions (pre-5.6), the annotation was @EnableGlobalMethodSecurity(prePostEnabled = true). If you’re upgrading from an older codebase, check that you’ve migrated to @EnableMethodSecurity.
There’s also a subtler gotcha: @PreAuthorize only works when called through a Spring proxy. If you call an annotated method from within the same class, the proxy is bypassed and the annotation is ignored.
@Service
public class UserService {
@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long id) { /* ... */ }
public void someOtherMethod(Long id) {
this.deleteUser(id); // ❌ Bypasses the proxy — @PreAuthorize ignored!
}
}
The fix is to inject the bean into itself or restructure so the annotated method is called from outside the class.
Cause #5: Multiple SecurityFilterChain Beans Conflicting
Spring Security 5.4+ supports multiple SecurityFilterChain beans for different URL patterns. This is powerful but easy to misuse. If you define two filter chains and both match a URL, Spring uses the one with higher priority (lower @Order value). The second chain’s rules are completely ignored for that request.
// ❌ Both chains match /api/** — only the first one (order=1) runs:
@Bean
@Order(1)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); // Too permissive?
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain webChain(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**") // Also matches /api/**!
.authorizeHttpRequests(auth -> auth.anyRequest().hasRole("USER"));
return http.build();
}
Use debug logging to see which filter chain is handling your request:
DEBUG FilterChainProxy - Securing GET /api/users
DEBUG FilterChainProxy - Matched [apiChain (securityMatcher: /api/**)]
Make sure your security matchers don’t overlap, or if they do, that the ordering produces the behavior you want.
Cause #6: Anonymous User vs Unauthenticated
Here’s a subtle one: Spring Security creates an AnonymousAuthenticationToken for requests with no credentials. This anonymous user has the authority "ROLE_ANONYMOUS". If your rule is authenticated(), anonymous users are rejected with 403 (or 401 if configured).
But here’s the trap: if you check the SecurityContext manually and find a non-null Authentication, you might think the user is authenticated — but they’re not.
// ❌ This is true even for anonymous users:
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null) { // AnonymousAuthenticationToken is non-null!
// You might incorrectly assume the user is authenticated
}
// ✅ Correct check:
if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) {
// User is genuinely authenticated
}
In your security config, use authenticated() rather than checking for non-null authentication, since authenticated() correctly excludes anonymous tokens.
Still Getting 403? Edge Cases to Check
JWT filter not registering: If you’re adding a custom JWT filter with addFilterBefore, verify it’s actually running. Add a log statement inside doFilterInternal to confirm.
Principal not set after JWT validation: Your JWT filter might parse the token successfully but forget to set the Authentication in the SecurityContextHolder. Without this, Spring treats the request as anonymous.
// ✅ Required inside your JWT filter after validating the token:
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken); // Don't forget this!
Spring Boot test slices: If you’re using @WebMvcTest, Spring Security is active by default. Tests that don’t set up authentication will get 403. Use @WithMockUser to simulate an authenticated user, or @WithMockUser(roles = "ADMIN") for role-specific tests.
@Test
@WithMockUser(roles = "ADMIN")
void adminEndpointShouldReturn200() throws Exception {
mockMvc.perform(get("/admin/dashboard"))
.andExpect(status().isOk());
}
Actuator endpoints: Spring Boot Actuator endpoints have their own security defaults. Adding management.endpoints.web.exposure.include=* doesn’t automatically grant access — you still need to configure security rules for /actuator/**.
Summary Checklist
Work through these in order:
- [ ] Enable
DEBUGlogging fororg.springframework.securityand look for the specific denial reason - [ ] Check if the request is POST/PUT/DELETE and CSRF might be blocking it
- [ ] Verify role names:
hasRole("ADMIN")requires the authority to be"ROLE_ADMIN" - [ ] Confirm
@EnableMethodSecurityis present if using@PreAuthorize - [ ] Check for multiple
SecurityFilterChainbeans with overlapping matchers - [ ] Verify your JWT filter sets
SecurityContextHolder.getContext().setAuthentication(...)after validating tokens - [ ] In tests, use
@WithMockUseror configureSecurityMockMvcRequestPostProcessors
Once you’ve identified the stack trace or error log related to the 403, use Debugly’s trace formatter to quickly parse and analyze Java stack traces and pinpoint the exact filter or voter that rejected the request.
Spring Security’s debug output combined with a formatted stack trace gets you from “why is this 403” to “here’s the exact line causing it” in minutes rather than hours. The ExceptionTranslationFilter and FilterSecurityInterceptor entries in the trace are usually where the story ends — and where your fix begins.
For related issues, check out our guides on Spring BeanCreationException troubleshooting and @Transactional not working in Spring.