Fix: Spring Security Returning 403 Forbidden Unexpectedly
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
authorizeHttpRequestsconfig — a security rule blocks a path that should be permitted, or the rules are in the wrong order. - Method-level security —
@PreAuthorize,@Secured, or@RolesAllowedon a method denies access even if the URL is permitted. - Missing role prefix —
hasRole('ADMIN')requires the authority to beROLE_ADMIN. UsinghasAuthority('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 endpointFix 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 consistentCheck 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 viathis.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: DEBUGThe 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 handlerEnable 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 securityFor related Spring Boot issues, see Fix: Spring Boot WhiteLabel Error Page and Fix: Spring Boot DataSource Failed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Hibernate LazyInitializationException — Could Not Initialize Proxy
How to fix Hibernate LazyInitializationException — loading lazy associations outside an active session, fetch join, @Transactional scope, DTO projection, and Open Session in View.
Fix: Spring Boot "The dependencies of some of the beans in the application context form a cycle"
How to fix Spring Boot circular dependency errors — BeanCurrentlyInCreationException, refactoring to break cycles, @Lazy injection, setter injection, and @PostConstruct patterns.
Fix: Spring Boot Failed to Configure DataSource (DataSource Auto-Configuration Error)
How to fix Spring Boot 'Failed to configure a DataSource' errors — missing URL property, driver class not found, connection refused, and how to correctly configure datasource properties for MySQL, PostgreSQL, and H2.
Fix: Spring BeanCreationException: Error creating bean with name
How to fix Spring BeanCreationException error creating bean caused by missing dependencies, circular references, wrong annotations, configuration errors, and constructor issues.