Skip to content

Fix: Spring Security Returning 403 Forbidden Unexpectedly

FixDevs ·

Quick Answer

How to fix Spring Security 403 Forbidden errors — CSRF token missing, incorrect security configuration, method security blocking requests, and how to debug the Spring Security filter chain.

The Error

Your Spring Boot application returns 403 Forbidden even for routes that should be accessible:

HTTP/1.1 403 Forbidden
{
  "timestamp": "2026-03-18T10:00:00.000+00:00",
  "status": 403,
  "error": "Forbidden",
  "path": "/api/users"
}

Or a specific endpoint returns 403 while others work. Or a POST/PUT/DELETE request returns 403 but GET works fine. Or a user with the right roles still gets 403.

Why This Happens

Spring Security 403 errors come from several layers:

  • CSRF protection — by default, Spring Security requires a CSRF token for state-changing requests (POST, PUT, DELETE, PATCH). Missing or invalid CSRF tokens return 403.
  • Incorrect authorizeHttpRequests config — a security rule blocks a path that should be permitted, or the rules are in the wrong order.
  • Method-level security@PreAuthorize, @Secured, or @RolesAllowed on a method denies access even if the URL is permitted.
  • Missing role prefixhasRole('ADMIN') requires the authority to be ROLE_ADMIN. Using hasAuthority('ADMIN') without the prefix mismatch.
  • Principal not authenticated — the user is anonymous (not logged in), and the resource requires authentication.
  • JWT token issues — in stateless REST APIs, an expired, malformed, or missing JWT causes 403 or 401.

Fix 1: Fix CSRF for REST APIs

If your API is stateless (JWT-based, no sessions), disable CSRF protection — CSRF attacks require session cookies, which stateless APIs don’t use:

// Spring Security 6.x (Spring Boot 3.x)
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())  // Disable CSRF for stateless REST API
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

For traditional web apps that use sessions — include CSRF token in requests:

// Keep CSRF enabled (default) — include token in requests
// Spring Security auto-adds CSRF cookie with CookieCsrfTokenRepository
http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
);
// Frontend — read CSRF cookie and send as header
const csrfToken = document.cookie
  .split('; ')
  .find(row => row.startsWith('XSRF-TOKEN='))
  ?.split('=')[1];

fetch('/api/users', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-XSRF-TOKEN': csrfToken,  // Required by Spring Security
  },
  body: JSON.stringify(newUser),
});

Fix 2: Fix authorizeHttpRequests Rule Order

Spring Security evaluates rules in order — the first matching rule wins. More specific rules must come before more general ones:

// Wrong — anyRequest().authenticated() catches everything before specific permits
http.authorizeHttpRequests(auth -> auth
    .anyRequest().authenticated()          // ← Catches /api/auth/login too
    .requestMatchers("/api/auth/**").permitAll()  // ← Never reached
);

// Correct — specific rules first, general rules last
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/auth/**").permitAll()         // Public auth endpoints
    .requestMatchers("/api/public/**").permitAll()       // Public content
    .requestMatchers("/actuator/health").permitAll()     // Health check
    .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // CORS preflight
    .requestMatchers("/api/admin/**").hasRole("ADMIN")   // Admin only
    .requestMatchers("/api/**").authenticated()          // All other API endpoints
    .anyRequest().permitAll()                            // Static files, etc.
);

Common paths that need explicit permitting:

.requestMatchers("/", "/index.html", "/static/**", "/favicon.ico").permitAll()
.requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/error").permitAll()  // Spring Boot's error endpoint

Fix 3: Fix Role and Authority Mismatches

hasRole('ADMIN') internally prefixes the role with ROLE_ — the user’s GrantedAuthority must be ROLE_ADMIN:

// hasRole('ADMIN') checks for authority 'ROLE_ADMIN'
.requestMatchers("/admin/**").hasRole("ADMIN")

// hasAuthority('ADMIN') checks for authority 'ADMIN' (no prefix)
.requestMatchers("/admin/**").hasAuthority("ADMIN")

// These are NOT interchangeable — pick one and be consistent

Check what authorities the user actually has:

// Debug endpoint — add temporarily to check user details
@GetMapping("/debug/auth")
public Map<String, Object> debugAuth(Authentication authentication) {
    if (authentication == null) {
        return Map.of("authenticated", false);
    }
    return Map.of(
        "authenticated", authentication.isAuthenticated(),
        "name", authentication.getName(),
        "authorities", authentication.getAuthorities().toString(),
        "principal", authentication.getPrincipal().toString()
    );
}

Ensure UserDetailsService returns authorities with the correct prefix:

@Service
public class CustomUserDetailsService implements UserDetailsService {

    @Override
    public UserDetails loadUserByUsername(String username) {
        User user = userRepository.findByEmail(username)
            .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));

        // Correct — prefix with ROLE_ for use with hasRole()
        List<GrantedAuthority> 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.getPasswordHash(),
            authorities
        );
    }
}

Fix 4: Fix Method-Level Security

@PreAuthorize, @Secured, and @RolesAllowed apply security at the method level — they can block access even if the URL rule permits it:

// Enable method security (required for @PreAuthorize to work)
@Configuration
@EnableMethodSecurity  // Spring Security 6.x
// @EnableGlobalMethodSecurity(prePostEnabled = true)  // Spring Security 5.x
public class MethodSecurityConfig {}

// Service with method-level security
@Service
public class UserService {

    @PreAuthorize("hasRole('ADMIN')")  // Only ADMIN can call this
    public List<User> getAllUsers() {
        return userRepository.findAll();
    }

    @PreAuthorize("hasRole('USER') and #userId == authentication.principal.id")
    public User getUser(Long userId) {  // Users can only get their own data
        return userRepository.findById(userId).orElseThrow();
    }

    @PreAuthorize("hasAnyRole('ADMIN', 'MANAGER')")
    public void deleteUser(Long userId) {
        userRepository.deleteById(userId);
    }
}

Debug method security by checking the security expression:

// If @PreAuthorize("hasRole('ADMIN')") is blocking you, check:
// 1. Is method security enabled? (@EnableMethodSecurity)
// 2. Is the user authenticated? (authentication != null)
// 3. Does the user have ROLE_ADMIN? (check /debug/auth endpoint)
// 4. Is the method being proxied? (Spring AOP — must call via Spring proxy, not directly)

Common Mistake: Calling a @PreAuthorize-annotated method from within the same class bypasses Spring AOP. The annotation only works when the method is called through the Spring-managed proxy (i.e., from a different bean). Inject the service bean and call it through that, not via this.method().

Fix 5: Fix JWT Authentication Returning 403

For stateless JWT-based APIs, 403 often means the token was validated but the user lacks the required permissions. 401 means the token is missing or invalid. Make sure you return the correct status:

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {

        String authHeader = request.getHeader("Authorization");

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = authHeader.substring(7);

        try {
            String username = jwtUtil.extractUsername(token);

            if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);

                if (jwtUtil.isTokenValid(token, userDetails)) {
                    UsernamePasswordAuthenticationToken authToken =
                        new UsernamePasswordAuthenticationToken(
                            userDetails, null, userDetails.getAuthorities()
                        );
                    authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(authToken);
                }
            }
        } catch (ExpiredJwtException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  // 401 — token expired
            response.getWriter().write("{\"error\": \"Token expired\"}");
            return;
        } catch (JwtException e) {
            response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);  // 401 — invalid token
            response.getWriter().write("{\"error\": \"Invalid token\"}");
            return;
        }

        filterChain.doFilter(request, response);
    }
}

Configure the access denied handler to return JSON instead of HTML:

http.exceptionHandling(ex -> ex
    .authenticationEntryPoint((request, response, authException) -> {
        // 401 — not authenticated
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\": \"Unauthorized\"}");
    })
    .accessDeniedHandler((request, response, accessDeniedException) -> {
        // 403 — authenticated but lacks permission
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType("application/json");
        response.getWriter().write("{\"error\": \"Forbidden\"}");
    })
);

Fix 6: Enable Spring Security Debug Logging

# application.yml — enable detailed security logging
logging:
  level:
    org.springframework.security: DEBUG
    org.springframework.security.web.FilterChainProxy: DEBUG
    org.springframework.security.access: DEBUG

The debug output shows every filter in the security chain, which filter is running, and why access was denied:

DEBUG FilterChainProxy - Securing GET /api/admin/users
DEBUG FilterSecurityInterceptor - Authorized filter invocation [GET /api/admin/users] with attributes [authenticated]
DEBUG AffirmativeBased - Voter: org.springframework.security.access.vote.RoleVoter@..., returned: -1
DEBUG ExceptionTranslationFilter - Sending AnonymousAuthenticationToken to access denied handler

Enable security debug mode in tests:

@SpringBootTest
@AutoConfigureMockMvc
class SecurityTest {

    @Test
    @WithMockUser(roles = "ADMIN")
    void adminCanAccessAdminEndpoint() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(roles = "USER")
    void userCannotAccessAdminEndpoint() throws Exception {
        mockMvc.perform(get("/api/admin/users"))
            .andExpect(status().isForbidden());
    }

    @Test
    void anonymousCannotAccessProtectedEndpoint() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(status().isUnauthorized());
    }
}

Still Not Working?

Check the security filter chain order. Multiple SecurityFilterChain beans are ordered — the first matching chain handles the request:

@Bean
@Order(1)  // Higher priority
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
    http.securityMatcher("/api/**")  // Only applies to /api/** requests
        .csrf(csrf -> csrf.disable())
        ...
    return http.build();
}

@Bean
@Order(2)  // Lower priority — handles everything else
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .formLogin(Customizer.withDefaults());
    return http.build();
}

Verify the security configuration is actually loading. Add a log statement to the @Configuration class constructor or use the actuator endpoint:

# With Spring Boot Actuator — shows all beans including security filters
curl http://localhost:8080/actuator/beans | python3 -m json.tool | grep -i security

For related Spring Boot issues, see Fix: Spring Boot WhiteLabel Error Page and Fix: Spring Boot DataSource Failed.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles