Skip to content

Fix: Spring Security Returning 403 Forbidden Unexpectedly

FixDevs · (Updated: )

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 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.

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 WebFlux security 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 WebSecurityConfigurerAdapter in favor of bean-based configuration: declare a SecurityFilterChain @Bean directly. Most “Spring Security tutorial from 2021” content uses the now-deprecated adapter style.
  • Spring Security 5.8 (November 2022) — Deprecated authorizeRequests() in favor of authorizeHttpRequests(). The new method uses AuthorizationManager instead of legacy voters and supports requestMatchers(...) instead of antMatchers(...).
  • Spring Security 6.0 (November 2022) — Major release tied to Spring Boot 3.0. Requires Java 17 and Servlet 6 (Jakarta EE namespace). Removed WebSecurityConfigurerAdapter and the legacy antMatchers/mvcMatchers methods. The OAuth2 client and resource server modules were redesigned with new property prefixes (spring.security.oauth2.client.registration.*). OncePerRequestFilter moved to jakarta.servlet.* packages — any custom filter still importing javax.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 RequestMatcher interface.
  • Spring Security 6.2 (November 2023) — Reworked the CSRF protection defaults to use a XorCsrfTokenRequestAttributeHandler by default, which breaks frontends that read the cookie value directly. You either upgrade the frontend or set csrf.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) to restore the old behavior.
  • Spring Security 6.3 (May 2024) — Added new oneTimeTokenLogin() and password compromise detection. Default password encoder remains delegatingPasswordEncoder("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 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

Check 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.

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