HomeSpring BootSpring Boot Security Configuration: SecurityFilterChain, CORS, CSRF, Headers and Actuator
Spring Boot

Spring Boot Security Configuration: SecurityFilterChain, CORS, CSRF, Headers and Actuator

Spring Security configuration centres on the SecurityFilterChain bean. Every HTTP request passes through an ordered chain of filters before it reaches a controller. This guide shows a working JWT API setup, CORS bound to environment-specific properties, CSRF for both stateless APIs and cookie-based SPAs, security headers, multiple filter chains for mixed authentication strategies, and locked-down Actuator endpoints. It also covers the mistakes that most often cause 401, 403 and CORS errors. Examples target Spring Security 6.x (Spring Boot 3.x).

How Spring Security Processes a Request

Spring Security is a chain of servlet filters that runs before your controllers. Each SecurityFilterChain bean is a complete chain with its own matcher, filters and rules. For an incoming request, Spring picks the first chain (lowest @Order) whose securityMatcher matches the request and runs only that chain. If a chain has no securityMatcher it matches everything, so it must be the last one. Inside a chain, authorizeHttpRequests rules are also evaluated top to bottom and the first matching rule wins. Put specific rules first and anyRequest() last. A broad rule placed early silently overrides everything below it. Two outcomes to keep in mind: a request with no valid authentication that reaches a protected rule is sent to the AuthenticationEntryPoint (HTTP 401), while an authenticated user who lacks the required role is sent to the AccessDeniedHandler (HTTP 403). This guide uses the lambda DSL, which is required in current versions. WebSecurityConfigurerAdapter and the old and() chaining style were removed in Spring Security 6. If you are moving to Spring Boot 4 and Spring Security 7, check the official migration guide, because some previously deprecated methods are removed there.
Text
Request
  │
  ▼
Pick the first SecurityFilterChain (by @Order) whose securityMatcher matches
  │
  ▼
CORS → CSRF → [JwtAuthenticationFilter] → ... → AuthorizationFilter
  │                                                   │
  │  no/invalid authentication ──────────────► AuthenticationEntryPoint (401)
  │  authenticated but wrong role ───────────► AccessDeniedHandler     (403)
  ▼
Controller

Dependencies

The starter adds Spring Security and its auto-configuration. Add spring-security-test for MockMvc helpers such as @WithMockUser and csrf(). Versions come from the Spring Boot parent, so do not set them yourself. The JWT parsing library is your choice (jjwt, Nimbus or the Spring OAuth2 resource server) and is not shown here.
XML
<!-- pom.xml -->
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

SecurityFilterChain Setup

SecurityFilterChain is the central Spring Security bean. Declare it in a @Configuration class annotated with @EnableWebSecurity. This example secures a stateless JSON API under /api/** with JWT bearer tokens. A few points that differ from many tutorials: You do not need to declare a DaoAuthenticationProvider bean. If your application has exactly one UserDetailsService bean and one PasswordEncoder bean, Spring builds the provider for you, and the AuthenticationManager from AuthenticationConfiguration uses it. Declaring your own provider is only needed for custom behavior, and constructor signatures for it change between Spring Security versions. The CORS source is a separate bean (next section), so the chain asks for it with cors(Customizer.withDefaults()), which picks up the bean named corsConfigurationSource. @EnableMethodSecurity already enables @PreAuthorize and @PostAuthorize; prePostEnabled defaults to true. The GET rule below gives any authenticated user with one of the three roles read access, but note that every other method on /api/v1/** falls through to anyRequest().authenticated(), which allows any logged-in user to POST, PUT and DELETE. If that is not what you want, add explicit rules per method and path, or use @PreAuthorize on the controller methods.
Java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity            // enables @PreAuthorize / @PostAuthorize
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;
    private final JwtAuthenticationEntryPoint authEntryPoint;
    private final JwtAccessDeniedHandler accessDeniedHandler;

    public SecurityConfig(JwtAuthenticationFilter jwtFilter,
                          JwtAuthenticationEntryPoint authEntryPoint,
                          JwtAccessDeniedHandler accessDeniedHandler) {
        this.jwtFilter = jwtFilter;
        this.authEntryPoint = authEntryPoint;
        this.accessDeniedHandler = accessDeniedHandler;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        // DelegatingPasswordEncoder: stores hashes as {bcrypt}..., allows future migration
        return PasswordEncoderFactories.createDelegatingPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config)
            throws Exception {
        // Uses your single UserDetailsService + PasswordEncoder beans automatically
        return config.getAuthenticationManager();
    }

    // Prevents Spring Boot from ALSO registering the JWT filter as a plain servlet filter,
    // which would run it a second time outside the security chain:
    @Bean
    public FilterRegistrationBean<JwtAuthenticationFilter> jwtFilterRegistration(
            JwtAuthenticationFilter filter) {
        FilterRegistrationBean<JwtAuthenticationFilter> registration =
            new FilterRegistrationBean<>(filter);
        registration.setEnabled(false);
        return registration;
    }

    @Bean
    @Order(2)
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(AbstractHttpConfigurer::disable)            // JWT in header, no cookies
            .cors(Customizer.withDefaults())                  // uses corsConfigurationSource bean
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(authEntryPoint)     // 401
                .accessDeniedHandler(accessDeniedHandler))    // 403
            .authorizeHttpRequests(auth -> auth
                // Most specific rules first:
                .requestMatchers("/api/v1/auth/login", "/api/v1/auth/register",
                                 "/api/v1/public/**").permitAll()
                .requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.GET, "/api/v1/**")
                    .hasAnyRole("USER", "MANAGER", "ADMIN")
                .anyRequest().authenticated())                // catch-all LAST
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }
}

JWT Filter, Entry Point and Access Denied Handler

The configuration above references three classes. The JWT filter reads the Bearer token, validates it, and stores an Authentication in the SecurityContext. It never rejects a request itself: if the token is missing or invalid it simply leaves the context empty and continues, and the authorization rules then trigger the entry point (401). The entry point and the access denied handler return JSON instead of the default HTML or empty response, which is what API clients expect. JwtService is your own class that verifies the signature and expiry and reads the subject; the example catches JwtException, which is the base exception in the jjwt library, so adjust the exception type if you use a different library. Role names matter. hasRole("ADMIN") checks for the authority ROLE_ADMIN. If your users are loaded with authorities named just ADMIN, hasRole will never match and every call returns 403. Either store roles with the ROLE_ prefix or use hasAuthority("ADMIN").
Java
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;
    private final UserDetailsService userDetailsService;

    public JwtAuthenticationFilter(JwtService jwtService,
                                   UserDetailsService userDetailsService) {
        this.jwtService = jwtService;
        this.userDetailsService = userDetailsService;
    }

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

        String header = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (header != null && header.startsWith("Bearer ")
                && SecurityContextHolder.getContext().getAuthentication() == null) {
            try {
                String token = header.substring(7);
                if (jwtService.isValid(token)) {
                    UserDetails user =
                        userDetailsService.loadUserByUsername(jwtService.getUsername(token));
                    UsernamePasswordAuthenticationToken auth =
                        UsernamePasswordAuthenticationToken.authenticated(
                            user, null, user.getAuthorities());
                    auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    SecurityContextHolder.getContext().setAuthentication(auth);
                }
            } catch (JwtException | UsernameNotFoundException ex) {
                SecurityContextHolder.clearContext();   // treated as unauthenticated -> 401
            }
        }
        chain.doFilter(request, response);
    }
}

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper mapper;

    public JwtAuthenticationEntryPoint(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response,
                         AuthenticationException ex) throws IOException {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        mapper.writeValue(response.getOutputStream(), Map.of(
            "status", 401,
            "error", "unauthorized",
            "message", "Authentication is required to access this resource"));
    }
}

@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {

    private final ObjectMapper mapper;

    public JwtAccessDeniedHandler(ObjectMapper mapper) {
        this.mapper = mapper;
    }

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException ex) throws IOException {
        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        mapper.writeValue(response.getOutputStream(), Map.of(
            "status", 403,
            "error", "forbidden",
            "message", "You do not have permission to perform this action"));
    }
}

CORS Configuration: Java

CORS (Cross-Origin Resource Sharing) headers tell browsers which origins may call your API. Configure it through a CorsConfigurationSource bean and enable it in the filter chain. Do not rely on @CrossOrigin alone: Spring Security must know about CORS so that preflight OPTIONS requests are answered before authentication. Without that, the browser's preflight gets 401 and the real request is never sent. The allowed origins are bound with @ConfigurationProperties rather than @Value. A YAML list cannot be injected into a List field with @Value("${...}"); that approach fails at startup or only works for comma-separated strings. A properties record is type-safe and defaults to an empty list, which denies all cross-origin calls if you forget to configure it. setAllowCredentials(true) is only needed if the browser must send cookies. A JWT in the Authorization header does not require it. When credentials are allowed you cannot use the wildcard origin "*"; list exact origins or use origin patterns. Allowing the Authorization header in setAllowedHeaders is what lets the browser send the token; exposing it in setExposedHeaders is unnecessary unless your frontend reads that header from responses.
Java
@ConfigurationProperties(prefix = "app.cors")
public record CorsProperties(List<String> allowedOrigins) {
    public CorsProperties {
        allowedOrigins = allowedOrigins == null ? List.of() : allowedOrigins;  // empty = deny all
    }
}

@Configuration
@EnableConfigurationProperties(CorsProperties.class)
public class CorsConfig {

    // Bean name must be corsConfigurationSource so cors(Customizer.withDefaults()) finds it
    @Bean
    public CorsConfigurationSource corsConfigurationSource(CorsProperties props) {
        CorsConfiguration config = new CorsConfiguration();

        // Exact origins from configuration (never "*" in production):
        config.setAllowedOrigins(props.allowedOrigins());
        // Or pattern-based, e.g. all subdomains:
        // config.setAllowedOriginPatterns(List.of("https://*.myapp.com"));

        config.setAllowedMethods(List.of(
            "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"));

        config.setAllowedHeaders(List.of(
            HttpHeaders.AUTHORIZATION,
            HttpHeaders.CONTENT_TYPE,
            HttpHeaders.ACCEPT,
            "X-Requested-With",
            "X-API-Version",
            "X-Correlation-ID"));

        // Headers the browser's JavaScript is allowed to read from responses:
        config.setExposedHeaders(List.of("X-Correlation-ID", "X-Rate-Limit-Remaining"));

        // Only if the browser must send cookies. Not needed for a Bearer token header.
        config.setAllowCredentials(false);

        config.setMaxAge(3600L);   // browser may cache the preflight result for 1 hour

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", config);
        return source;
    }
}

CORS Configuration: Environment Properties

Keep the allowed origins in configuration so production never accepts localhost calls. Spring profiles override the default list: with the dev profile active, the list below replaces the production list entirely (lists are replaced, not merged). Set the profile with SPRING_PROFILES_ACTIVE=dev locally.
yaml
# application.yml (production default)
app:
  cors:
    allowed-origins:
      - https://myapp.com
      - https://www.myapp.com

# application-dev.yml (local development)
app:
  cors:
    allowed-origins:
      - http://localhost:3000
      - http://localhost:5173
      - http://127.0.0.1:3000

CSRF Protection: Stateless APIs

CSRF attacks work because browsers automatically attach cookies to cross-site requests. A stateless API that reads its JWT from the Authorization header is not exposed to this, because a malicious site cannot make the browser add that header. In this case it is correct to disable CSRF. There is an important exception. If you store the JWT in a cookie, the browser sends it automatically and CSRF protection is needed again. Disabling CSRF is only safe when authentication is carried in a header that scripts must set themselves. Each snippet in this section and the next is an alternative for a different application type; they are not meant to all be declared in the same project, because several chains without matchers conflict.
Java
// Stateless REST API: JWT in the Authorization header
@Bean
public SecurityFilterChain statelessApiChain(HttpSecurity http) throws Exception {
    return http
        .csrf(AbstractHttpConfigurer::disable)
        .sessionManagement(sm -> sm
            .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .build();
}

CSRF Protection: Single-Page Apps with Cookies

For a SPA that authenticates with a session cookie, keep CSRF on and use CookieCsrfTokenRepository.withHttpOnlyFalse(). Spring then sets an XSRF-TOKEN cookie that JavaScript can read, and the SPA sends its value back in an X-XSRF-TOKEN header on POST, PUT, PATCH and DELETE requests. Since Spring Security 6, the default handler masks the token against BREACH attacks (XorCsrfTokenRequestAttributeHandler) and loads it lazily. Two things must be handled for a SPA to work: the raw cookie value sent by JavaScript must be accepted, and the token must actually be written to the cookie on the first response. The handler and filter below, which follow the pattern in the Spring Security reference documentation, do both. Using the Xor handler alone causes every SPA request to fail with 403 because the unmasked cookie value does not match.
Java
@Bean
public SecurityFilterChain spaChain(HttpSecurity http) throws Exception {
    return http
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
        .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
        .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
        .build();
}

// Accepts the raw token from the X-XSRF-TOKEN header (SPA) and the masked token from forms:
final class SpaCsrfTokenRequestHandler implements CsrfTokenRequestHandler {

    private final CsrfTokenRequestHandler plain = new CsrfTokenRequestAttributeHandler();
    private final CsrfTokenRequestHandler xor = new XorCsrfTokenRequestAttributeHandler();

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       Supplier<CsrfToken> csrfToken) {
        xor.handle(request, response, csrfToken);   // keeps BREACH protection for rendered tokens
    }

    @Override
    public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
        String headerValue = request.getHeader(csrfToken.getHeaderName());
        return (StringUtils.hasText(headerValue) ? plain : xor)
            .resolveCsrfTokenValue(request, csrfToken);
    }
}

// Forces the deferred token to load so the XSRF-TOKEN cookie is set on every response:
final class CsrfCookieFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain chain) throws ServletException, IOException {
        CsrfToken token = (CsrfToken) request.getAttribute("_csrf");
        if (token != null) {
            token.getToken();   // triggers cookie write
        }
        chain.doFilter(request, response);
    }
}

CSRF Protection: Excluding Webhooks

Server-to-server callbacks such as payment webhooks cannot send a CSRF token, so exclude their paths with ignoringRequestMatchers. Excluding a path from CSRF removes one layer of protection, so authenticate these endpoints another way: verify the provider's HMAC signature header (for Stripe, the Stripe-Signature header) and reject unsigned calls. Exclude only the exact webhook paths, never a wide pattern.
Java
@Bean
public SecurityFilterChain webhookAwareChain(HttpSecurity http) throws Exception {
    return http
        .csrf(csrf -> csrf
            .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())
            .ignoringRequestMatchers(
                "/api/v1/webhooks/stripe",      // verified with the provider's HMAC signature
                "/api/v1/webhooks/paypal-ipn")) // verified by calling back the provider
        .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/v1/webhooks/**").permitAll()  // auth is the signature check
            .anyRequest().authenticated())
        .build();
}

Security Headers

HTTP security headers tell browsers to enforce extra protections. Spring Security adds sensible defaults (X-Content-Type-Options, cache control, X-Frame-Options and HSTS over HTTPS), and the DSL lets you tune them. Notes on the settings below. HSTS is only sent on HTTPS responses; browsers ignore it over plain HTTP. Add preload only when you are certain every subdomain supports HTTPS permanently, because preload is hard to undo. The permissions policy method was renamed permissionsPolicyHeader in Spring Security 6.4, and the older permissionsPolicy method is deprecated. Content-Security-Policy mostly matters for HTML pages. A pure JSON API can use the strictest possible policy, shown first. If your Spring Boot application serves HTML, adapt the second policy, and avoid 'unsafe-inline' for scripts; it defeats most of the protection of CSP. Allowing inline styles is a smaller risk but still weakens the policy, so prefer nonces or hashes if you can.
Java
// ── JSON API: strictest policy ───────────────────────────────────────────
@Bean
public SecurityFilterChain apiHeadersChain(HttpSecurity http) throws Exception {
    return http
        .headers(headers -> headers
            .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)   // clickjacking
            .contentTypeOptions(Customizer.withDefaults())              // no MIME sniffing
            .httpStrictTransportSecurity(hsts -> hsts
                .includeSubDomains(true)
                .preload(true)                    // only if every subdomain is HTTPS-only
                .maxAgeInSeconds(31536000))       // 1 year
            .referrerPolicy(rp -> rp.policy(
                ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
            .permissionsPolicyHeader(pp -> pp
                .policy("camera=(), microphone=(), geolocation=()"))
            .contentSecurityPolicy(csp -> csp
                .policyDirectives("default-src 'none'; frame-ancestors 'none'")))
        .build();
}

// ── Application that serves HTML: adapt this policy to your assets ───────
// .contentSecurityPolicy(csp -> csp.policyDirectives(
//     "default-src 'self'; " +
//     "script-src 'self'; " +
//     "style-src 'self'; " +
//     "img-src 'self' data: https:; " +
//     "font-src 'self'; " +
//     "connect-src 'self'; " +
//     "frame-ancestors 'none'; " +
//     "base-uri 'self'; " +
//     "form-action 'self'"))

Multiple Filter Chains

Different parts of an application can use different security strategies: Basic auth for actuator, API keys for a public API, JWT for the private API and form login for the web UI. Use @Order to control precedence (lower runs first). A request is handled by the first chain whose securityMatcher matches, so list narrow matchers first and make the last chain the catch-all with no securityMatcher. This configuration replaces the single apiFilterChain from earlier, not adds to it. Two chains with the same @Order, or two chains with no matcher, cause a startup error. Keep every @Order value unique and every matcher consistent: the public API is at /api/public/**, so do not also permit /api/v1/public/** in another chain. As in the single-chain example, every custom filter registered as a @Component (here the JWT and API-key filters) must have its automatic servlet registration disabled, otherwise it runs twice. Define a FilterRegistrationBean with setEnabled(false) for each. The actuator chain uses EndpointRequest.toAnyEndpoint() instead of the string "/actuator/**". It follows your management.endpoints.web.base-path setting and works if actuator runs on a separate management port. The actuator accounts are supplied with http.userDetailsService(...) on this chain only, instead of exposing a second UserDetailsService bean. A second bean would replace or conflict with your application's main UserDetailsService, breaking API login.
Java
@Configuration
@EnableWebSecurity
public class MultiChainSecurityConfig {

    private final JwtAuthenticationFilter jwtFilter;
    private final ApiKeyAuthFilter apiKeyFilter;

    public MultiChainSecurityConfig(JwtAuthenticationFilter jwtFilter,
                                    ApiKeyAuthFilter apiKeyFilter) {
        this.jwtFilter = jwtFilter;
        this.apiKeyFilter = apiKeyFilter;
    }

    // ── Chain 1: Actuator, its own credentials (see Actuator Security) ───────
    @Bean
    @Order(1)
    public SecurityFilterChain actuatorChain(HttpSecurity http,
                                             ActuatorUsers actuatorUsers) throws Exception {
        return http
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .csrf(AbstractHttpConfigurer::disable)
            .userDetailsService(actuatorUsers.userDetailsService())   // not a global bean
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                .requestMatchers(EndpointRequest.to("metrics", "prometheus"))
                    .hasAnyRole("MONITORING", "ACTUATOR_ADMIN")
                .anyRequest().hasRole("ACTUATOR_ADMIN"))
            .httpBasic(Customizer.withDefaults())
            .build();
    }

    // ── Chain 2: Public API, API-key authentication ──────────────────────────
    @Bean
    @Order(2)
    public SecurityFilterChain publicApiChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/public/**")
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    // ── Chain 3: Private API, JWT authentication ─────────────────────────────
    @Bean
    @Order(3)
    public SecurityFilterChain privateApiChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(AbstractHttpConfigurer::disable)
            .cors(Customizer.withDefaults())
            .sessionManagement(sm -> sm
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/v1/auth/login", "/api/v1/auth/register").permitAll()
                .anyRequest().authenticated())
            .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
    }

    // ── Chain 4: Web UI, form login with a session (catch-all, no matcher) ───
    @Bean
    @Order(4)
    public SecurityFilterChain webChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/", "/login", "/register", "/css/**", "/js/**").permitAll()
                .anyRequest().authenticated())
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard"))
            .logout(logout -> logout
                .logoutUrl("/logout")
                .logoutSuccessUrl("/login?logout"))
            .build();
    }
}

Actuator Security: Properties

Spring Boot Actuator exposes operational data such as health, metrics, environment variables and beans. Expose only what you need, and require authentication for anything beyond health and info. Never expose env, configprops, heapdump, threaddump or beans to unauthenticated users in production: they can reveal secrets, internal hostnames and memory contents. Running Actuator on a separate management port (below, 8081) lets you block it at the firewall or load balancer so it is not reachable from the internet at all. Keep authentication on as well; network rules are an extra layer, not a replacement. The shutdown endpoint is disabled by default and is not in the exposure list. In Spring Boot 3.4 and later, the per-endpoint enabled flag is deprecated in favor of the access setting, so use access: none to make the intent explicit. On older versions use management.endpoint.shutdown.enabled=false. Passwords for actuator accounts must come from environment variables or a secret store, never from source control. The Java configuration below reads them from properties that you map to environment variables.
yaml
# application.yml
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus
        # Do NOT expose in production without strong auth:
        # env, configprops, heapdump, threaddump, shutdown, beans
  endpoint:
    health:
      show-details: when-authorized
      show-components: when-authorized
      roles: ACTUATOR_ADMIN        # roles allowed to see health details
    shutdown:
      access: none                 # Spring Boot 3.4+; older versions: enabled: false
  server:
    port: 8081                     # separate port: block it at the firewall

app:
  actuator:
    monitoring-password: ${MONITORING_PASSWORD}      # from an environment variable
    admin-password: ${ACTUATOR_ADMIN_PASSWORD}

Actuator Security: Filter Chain and Credentials

This is the standalone version of the actuator chain, for applications that do not use the multi-chain configuration above. The credentials live in a small component that builds an in-memory user store without registering it as a global UserDetailsService bean. Passwords are injected from properties, because writing "${MONITORING_PASSWORD}" inside a Java string does not resolve the placeholder; the literal text would become the password. EndpointRequest.to("metrics", "prometheus") uses endpoint IDs, which is safer than referencing endpoint classes. The Prometheus endpoint class has been renamed across Spring Boot versions, so a class reference can fail to compile after an upgrade.
Java
@Component
public class ActuatorUsers {

    private final PasswordEncoder encoder;
    private final String monitoringPassword;
    private final String adminPassword;

    public ActuatorUsers(PasswordEncoder encoder,
                         @Value("${app.actuator.monitoring-password}") String monitoringPassword,
                         @Value("${app.actuator.admin-password}") String adminPassword) {
        this.encoder = encoder;
        this.monitoringPassword = monitoringPassword;
        this.adminPassword = adminPassword;
    }

    // Not a @Bean on purpose: a second UserDetailsService bean would conflict with
    // the application's main one. It is attached to the actuator chain only.
    public UserDetailsService userDetailsService() {
        UserDetails monitoring = User.withUsername("monitoring")
            .password(encoder.encode(monitoringPassword))
            .roles("MONITORING")
            .build();

        UserDetails admin = User.withUsername("actuator-admin")
            .password(encoder.encode(adminPassword))
            .roles("ACTUATOR_ADMIN", "MONITORING")
            .build();

        return new InMemoryUserDetailsManager(monitoring, admin);
    }
}

@Configuration
public class ActuatorSecurityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain actuatorFilterChain(HttpSecurity http,
                                                   ActuatorUsers actuatorUsers) throws Exception {
        return http
            .securityMatcher(EndpointRequest.toAnyEndpoint())
            .csrf(AbstractHttpConfigurer::disable)
            .userDetailsService(actuatorUsers.userDetailsService())
            .authorizeHttpRequests(auth -> auth
                // Public: liveness probes need no credentials
                .requestMatchers(EndpointRequest.to("health", "info")).permitAll()
                // Metrics for the monitoring system
                .requestMatchers(EndpointRequest.to("metrics", "prometheus"))
                    .hasAnyRole("MONITORING", "ACTUATOR_ADMIN")
                // Everything else: admin only
                .anyRequest().hasRole("ACTUATOR_ADMIN"))
            .httpBasic(Customizer.withDefaults())
            .build();
    }
}

Testing Your Security Rules

Security rules are easy to break with a small change, so test them. spring-security-test provides @WithMockUser to run a request as a given user and role, and csrf() to add a valid CSRF token to a MockMvc request. Test at least these cases: anonymous request to a protected URL returns 401, a user without the role returns 403, a user with the role returns 200, a public URL works without credentials, and (for cookie-based apps) a POST without a CSRF token returns 403. If a test shows 403 where you expect 200, check the ROLE_ prefix first: @WithMockUser(roles = "ADMIN") creates the authority ROLE_ADMIN, which is what hasRole("ADMIN") expects.
Java
@SpringBootTest
@AutoConfigureMockMvc
class SecurityRulesTest {

    @Autowired
    MockMvc mockMvc;

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

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

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

    @Test
    void publicEndpointNeedsNoCredentials() throws Exception {
        mockMvc.perform(get("/api/v1/public/status"))
            .andExpect(status().isOk());
    }

    @Test
    void corsPreflightFromAllowedOriginSucceeds() throws Exception {
        mockMvc.perform(options("/api/v1/orders")
                .header("Origin", "https://myapp.com")
                .header("Access-Control-Request-Method", "GET"))
            .andExpect(status().isOk())
            .andExpect(header().string("Access-Control-Allow-Origin", "https://myapp.com"));
    }

    // For a cookie/CSRF-protected chain:
    // mockMvc.perform(post("/orders").with(csrf()))   -> allowed
    // mockMvc.perform(post("/orders"))                -> 403
}

Common Mistakes

1. Putting anyRequest() before specific rules. Rules are matched in order and the first match wins, so everything after a broad rule is dead code. Put specific rules first and anyRequest() last. 2. Using hasRole with authorities that lack the ROLE_ prefix. hasRole("ADMIN") checks for ROLE_ADMIN. If your user has the authority ADMIN, you get 403 on every call. Store roles with the prefix or use hasAuthority. 3. The JWT filter running twice. A filter declared as a @Component is also registered by Spring Boot as a normal servlet filter. Disable that with a FilterRegistrationBean set to enabled false, or construct the filter with new instead of making it a bean. 4. Preflight requests failing with 401. If CORS is not enabled in the security chain, the browser's OPTIONS request is rejected before CORS headers are added. Call cors(...) on the chain and provide a corsConfigurationSource bean. 5. Using allowCredentials(true) with allowedOrigins "*". Browsers reject this combination and Spring throws an error. List exact origins or use allowedOriginPatterns. 6. Injecting a YAML list with @Value. It does not bind. Use @ConfigurationProperties. 7. Disabling CSRF while keeping a cookie-based login. CSRF can be disabled only when authentication is carried by a header that attacker pages cannot set. If the token or session lives in a cookie, keep CSRF protection on. 8. Declaring several chains with the same @Order or with no matcher. Orders must be unique and only the last chain may omit securityMatcher. 9. Exposing a second UserDetailsService bean for a special case such as actuator. It replaces or conflicts with your main one. Attach special users to a single chain with http.userDetailsService(...). 10. Exposing sensitive actuator endpoints. env, heapdump and beans can leak secrets. Expose only health, info, metrics and prometheus, and require authentication for the last two. 11. Putting secrets in the code. Passwords, JWT signing keys and API keys belong in environment variables or a secrets manager. A "${...}" placeholder inside a Java string is not resolved; use @Value or @ConfigurationProperties. 12. Trusting client input for roles or identity. Always derive the user and roles from the validated token or session on the server.

Frequently Asked Questions

What replaced WebSecurityConfigurerAdapter? You declare one or more SecurityFilterChain beans instead of extending WebSecurityConfigurerAdapter, which was removed in Spring Security 6. Use the lambda DSL, for example http.authorizeHttpRequests(auth -> auth...). Do I need CSRF protection for a REST API with JWT? Not if the JWT is sent in the Authorization header, because browsers do not attach that header automatically. If the token is stored in a cookie, or you use session cookies, you do need CSRF protection. Why do I get 403 instead of 401 (or the other way around)? 401 means the request is not authenticated, and comes from the AuthenticationEntryPoint. 403 means the user is authenticated but not allowed, and comes from the AccessDeniedHandler. A 403 for an authenticated admin usually means a ROLE_ prefix mismatch. A 403 for a POST on a cookie-based app usually means a missing CSRF token. Without a configured entry point, some setups return 403 for anonymous requests as well. What is the difference between hasRole and hasAuthority? hasRole("ADMIN") checks for the authority ROLE_ADMIN. hasAuthority("ADMIN") checks for exactly ADMIN. Use one style consistently across your application. Should I use permitAll or web.ignoring for public resources? Prefer permitAll inside the filter chain. Requests still pass through the security filters, so headers and logging are applied. Ignoring a path skips the whole chain, including security headers, and is discouraged for dynamic endpoints. How do I secure only part of the application differently? Define several SecurityFilterChain beans with distinct securityMatcher values and unique @Order numbers, and make the last chain a catch-all without a matcher. See the Multiple Filter Chains section. Should the browser-facing CORS config also be set in the API gateway? Configure CORS in one place. If a gateway already adds CORS headers, adding them again in the service can produce duplicate headers, which browsers reject. Pick the gateway or the service, and de-duplicate if both must set them. Is it safe to expose the Actuator health endpoint publicly? Usually yes for health and info, with show-details set to when-authorized so details are hidden from anonymous callers. Keep everything else behind authentication, and prefer a separate management port blocked from the public internet.