The 403 Forbidden Deadlock
It is 2 AM. Your authentication logic looks flawless, the JWT is valid, and the user is definitely logged in. Yet, your console is flooding with this stack trace:
org.springframework.security.access.AccessDeniedException: Access is denied
at org.springframework.security.access.vote.AffirmativeBased.decide(AffirmativeBased.java:73)
at org.springframework.security.access.intercept.AbstractSecurityInterceptor.beforeInvocation(AbstractSecurityInterceptor.java:232)
On the frontend, you see a cold 403 Forbidden. This error is Spring Security’s way of saying: "I know who you are, but you don't have the right keys for this door." While an AuthenticationException means you failed to log in, an AccessDeniedException means your authorization level is insufficient.
Root Cause 1: The "ROLE_" Prefix Mismatch
Spring Security is opinionated about role naming. A frequent headache occurs when your database stores roles like ADMIN, but the security framework expects a specific prefix.
By default, the hasRole('ADMIN') expression checks for an authority named ROLE_ADMIN. If your database or JWT claims simply provide ADMIN, the check fails silently.
The Fix: Standardize Your Authorities
When loading user authorities, ensure the prefix is added manually if your source data lacks it:
// In your UserDetailsService
List<SimpleGrantedAuthority> authorities = user.getRoles().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.getName()))
.collect(Collectors.toList());
return new org.springframework.security.core.userdetails.User(
user.getEmail(),
user.getPassword(),
authorities
);
If you prefer to skip the prefix, use hasAuthority('ADMIN'). It matches the string exactly without appending ROLE_ under the hood.
Root Cause 2: Method Security is Dormant
Are your @PreAuthorize or @Secured annotations being ignored? You might be getting access denied even on public endpoints because the logic that processes those annotations hasn't been started.
The Fix: Wake Up Method Security
In Spring Boot 3.x, you must explicitly enable these checks in your configuration class using @EnableMethodSecurity:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // This is mandatory for @PreAuthorize to work
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// Configuration logic here
return http.build();
}
}
Pro tip: For Spring Boot 2.x, use @EnableGlobalMethodSecurity(prePostEnabled = true) instead.
Root Cause 3: CSRF Blocking State-Changing Requests
If GET requests work but POST, PUT, or DELETE calls fail with a 403, the hidden blocker is usually CSRF (Cross-Site Request Forgery) protection. Spring Security enables this by default to protect session-based apps.
The Fix: Configure CSRF for Your Architecture
For stateless REST APIs using JWTs, you should typically disable CSRF. Since you aren't using browser cookies for session management, the risk is mitigated:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // Safe for stateless JWT-based APIs
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
If you are using sessions, don't disable it. Instead, ensure your frontend sends the X-XSRF-TOKEN header with every mutating request.
Root Cause 4: The "Shadowing" Filter Chain
Spring Security processes matchers in a linear, top-to-bottom sequence. If a broad rule sits at the top, it will "shadow" the more specific rules beneath it.
// BROKEN CONFIGURATION
.authorizeHttpRequests(auth -> auth
.requestMatchers("/**").authenticated() // This swallows every request!
.requestMatchers("/api/admin/**").hasRole("ADMIN") // This code is unreachable
)
The Fix: Order by Specificity
Think of your filter chain as a funnel. Put the narrowest paths at the top and the catch-all rules at the bottom:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
.anyRequest().authenticated() // The catch-all goes last
)
How to Verify the Fix
Stop guessing. Enable debug logging in application.properties to see exactly why a request was rejected:
logging.level.org.springframework.security=DEBUG
Look for the AuthorizationFilter output in your logs. It will show a comparison like this:
DEBUG o.s.s.w.a.i.FilterSecurityInterceptor - Secure object: URL: /api/admin/users; Attributes: [hasRole('ROLE_ADMIN')]
If the log shows the user has [ADMIN] but the interceptor demands [ROLE_ADMIN], you have found your mismatch.
Quick Prevention Checklist
- Keep `DEBUG` logging on during local development for instant clarity.
- Standardize early: use `hasRole` (prefixed) OR `hasAuthority` (raw) across the whole project.
- When using JWTs, check that your `JwtAuthenticationConverter` is mapping claims to the correct authority format.
- Verify that a custom `AccessDeniedHandler` isn't accidentally masking 403 errors as 401s or 500s.

