Fixing Spring Security's 'Access is denied' (403 Forbidden) Errors

intermediate Java2026-05-23| Java 8+, Spring Boot 2.x/3.x, Spring Security 5.x/6.x

Error Message

org.springframework.security.access.AccessDeniedException: Access is denied
#java#spring-boot#spring-security#authorization#403

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.

Related Error Notes