Fix: Spring Security Returning 403 Forbidden Unexpectedly
Part of: Java & JVM Errors
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 is a chain of servlet filters that intercepts every request before your controller runs. A 403 always comes from one of those filters, never from your controller code — by the time the controller would be invoked, the request has already passed the security gate. Understanding which filter denied access is half the battle: FilterSecurityInterceptor (or its successor AuthorizationFilter in 6.x) handles URL-level rules, CsrfFilter rejects state-changing requests without a valid token, and MethodSecurityInterceptor enforces @PreAuthorize at the AOP layer after the controller has been resolved.
The most common confusion is the difference between 401 and 403. A 401 means “I do not know who you are” — the principal is anonymous or the credential is malformed. A 403 means “I know who you are, but you are not allowed to do this.” Spring Security’s default behavior for an anonymous user on a protected URL is 403, not 401, which surprises developers coming from other frameworks. The fix is usually to configure the AuthenticationEntryPoint so unauthenticated requests get 401 and only authorization failures get 403.
The other thicket is CSRF. Spring Security enables CSRF protection by default and exempts only GET, HEAD, OPTIONS, and TRACE. That is why a POST that worked in your tests returns 403 in the browser — the test ran as a server-side call without a token, while the browser must send X-XSRF-TOKEN to match the cookie. For stateless JWT APIs CSRF is unnecessary (no cookies to forge) and you simply disable it. For session-based MVC apps you keep it enabled and ensure the frontend includes the token. Method security adds a third layer that is invisible at the URL level: a @PreAuthorize annotation can deny access even when the URL rule said permitAll().
- 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.
Version History That Changes the Failure Mode
Spring Security has gone through several breaking changes in the last few years. Check org.springframework.security:spring-security-core in your build to know which dialect of the config DSL you should use:
- Spring Security 5.0 (November 2017) — Introduced the reactive
WebFluxsecurity module. - Spring Security 5.4 (October 2020) — Introduced the lambda DSL (
http.authorizeRequests(auth -> ...)) as an alternative to the legacy chained-builder form. - Spring Security 5.7 (May 2022) — Deprecated
WebSecurityConfigurerAdapterin favor of bean-based configuration: declare aSecurityFilterChain@Beandirectly. Most “Spring Security tutorial from 2021” content uses the now-deprecated adapter style. - Spring Security 5.8 (November 2022) — Deprecated
authorizeRequests()in favor ofauthorizeHttpRequests(). The new method usesAuthorizationManagerinstead of legacy voters and supportsrequestMatchers(...)instead ofantMatchers(...). - Spring Security 6.0 (November 2022) — Major release tied to Spring Boot 3.0. Requires Java 17 and Servlet 6 (Jakarta EE namespace). Removed
WebSecurityConfigurerAdapterand the legacyantMatchers/mvcMatchersmethods. The OAuth2 client and resource server modules were redesigned with new property prefixes (spring.security.oauth2.client.registration.*).OncePerRequestFiltermoved tojakarta.servlet.*packages — any custom filter still importingjavax.servlet.*will fail to compile. - Spring Security 6.1 (May 2023) — Made the lambda DSL the documented default and added support for the new Servlet 6
RequestMatcherinterface. - Spring Security 6.2 (November 2023) — Reworked the CSRF protection defaults to use a
XorCsrfTokenRequestAttributeHandlerby default, which breaks frontends that read the cookie value directly. You either upgrade the frontend or setcsrf.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())to restore the old behavior. - Spring Security 6.3 (May 2024) — Added new
oneTimeTokenLogin()and password compromise detection. Default password encoder remainsdelegatingPasswordEncoder("bcrypt").
The 5 → 6 jump is the one that produces the most “we suddenly get 403 everywhere” reports. The combination of jakarta.servlet.* namespace, removed adapter class, and renamed authorization API means any code copy-pasted from a 5.x article will not compile against 6.x. If you must support both, keep two configuration classes behind a profile.
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 securityCheck whether OAuth2 resource server is rejecting the token at the audience claim. When migrating to Spring Security 6’s OAuth2 resource server, the default validator now checks aud against the configured client ID. If your identity provider issues tokens with a different audience, every authenticated request returns 403 even with a valid bearer token. Configure a custom JwtDecoder with the right OAuth2TokenValidator chain.
Check that CORS is configured separately from authorization. A CORS preflight (OPTIONS) request without credentials must be allowed through the security chain. If you forgot requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() (or http.cors(Customizer.withDefaults()) plus a CorsConfigurationSource bean), browsers see 403 on the preflight and never send the real request. Curl from the terminal will work; the browser will fail.
Check the X-Forwarded- headers behind a proxy.* When Spring Boot runs behind a load balancer that terminates TLS, the SessionCreationPolicy.STATELESS setting can drop the authenticated principal if the forwarded headers are not parsed. Set server.forward-headers-strategy=framework in application.properties so Spring trusts the upstream headers, and confirm with /actuator/httpexchanges that the request URL contains the public host.
For related Spring Boot issues, see Fix: Spring Boot WhiteLabel Error Page, Fix: Spring Boot DataSource Failed, Fix: Spring Boot Failed to Configure DataSource, and Fix: Java Spring Bean Creation Exception.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Spring Boot @Cacheable Not Working — Cache Miss Every Time or Stale Data
How to fix Spring Boot @Cacheable issues — @EnableCaching missing, self-invocation bypass, key generation, TTL configuration, cache eviction, and Caffeine vs Redis setup.
Fix: Spring Data JPA Query Not Working — @Query, Derived Methods, and N+1 Problems
How to fix Spring Data JPA query issues — JPQL vs native SQL, derived method naming, @Modifying for updates, pagination, projections, and LazyInitializationException.
Fix: Spring Boot @Transactional Not Rolling Back — Transaction Committed Despite Exception
How to fix Spring @Transactional not rolling back — checked vs unchecked exceptions, self-invocation proxy bypass, rollbackFor, transaction propagation, and nested transactions.
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.