Configuring a SecurityFilterChain

Replace Boot's default security with your own SecurityFilterChain bean, configured through the lambda DSL, and split rules across several ordered chains.

Syntax@Bean SecurityFilterChain api(HttpSecurity http) throws Exception { http.securityMatcher("/api/**") // optional .authorizeHttpRequests(auth -> auth...) .httpBasic(Customizer.withDefaults()); return http.build(); }

You take control of web security by declaring a SecurityFilterChain bean. Spring Boot backs off as soon as one exists, so your bean becomes the whole configuration: anything you do not configure is simply not there. Write it in a @Configuration class and build it from the HttpSecurity builder Spring injects.

@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/actuator/health").permitAll()
            .anyRequest().authenticated())
        .httpBasic(Customizer.withDefaults())
        .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
        .csrf(csrf -> csrf.disable());
    return http.build();
}

The lambda DSL is the only DSL

Every section of HttpSecurity takes a Customizer: a lambda that configures that part. Older tutorials chain sections with .and() and call methods such as authorizeRequests() or antMatchers(); those were removed, and Spring Security 7 has no AntPathRequestMatcher at all. Path strings in requestMatchers are matched with Spring's PathPattern syntax, the same one your controllers use. Use Customizer.withDefaults() when you want a feature switched on with its standard settings.

You do not need @EnableWebSecurity in a Boot application; Boot applies it for you. Adding it is harmless, but it is not what makes your bean take effect.

More than one chain

Different parts of an application often need different rules: a stateless JSON API, an actuator port, maybe a few server-rendered admin pages with a login form. Declare several chains, give each a securityMatcher that limits which requests it handles, and order them with @Order. FilterChainProxy uses the first chain that matches, so the specific chains get the low order numbers and the catch-all chain, with no securityMatcher, comes last. A catch-all chain placed first silently swallows everything.

Mistakes that reach production

  • Forgetting the default rule. End every authorizeHttpRequests block with anyRequest(). authenticated() is a safe default; denyAll() is stricter and makes forgotten endpoints fail loudly.
  • Forgetting to permit /error. Spring Boot forwards errors to /error. If that path is not permitted, an unauthenticated caller who hits a 500 sees a 401 instead, which hides the real failure. Permitting it is normal; it only renders an error body.
  • Configuring a feature twice. A second call to a section configures the same configurer again: settings are overwritten or appended, and a second authorizeHttpRequests after anyRequest() fails at startup. Keep one place per concern.

Keep the configuration small and readable. It is the single most important file for an API's safety, and reviewers should be able to see every public path at a glance.

Example

Example · java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.HttpStatusEntryPoint;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health/**", "/error").permitAll()
                .requestMatchers("/api/public/**").permitAll()
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            // A JSON API has no browser session and no login page.
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable())
            .formLogin(form -> form.disable())
            // Plain 401 with no WWW-Authenticate pop-up in browsers.
            .exceptionHandling(ex -> ex.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED)));
        return http.build();
    }
}

When to use it

  • Make a JSON API stateless, with HTTP Basic or tokens, no login page and a clean 401 for missing credentials.
  • Keep actuator endpoints on their own rules so that only operators reach metrics while health stays public.
  • Add a correlation id filter that runs before authentication so rejected requests can be traced.

More examples

Two chains: API and actuator

The actuator chain owns <code>/actuator/**</code> and nothing else, so its rules cannot leak into the API. Health stays public for load balancers and Kubernetes probes, while every other endpoint needs an operator role. It is stateless with CSRF off like the API chain; with CSRF left on, an operator's <code>POST</code> to <code>/actuator/loggers</code> would be rejected with <code>403</code>. The API chain has no <code>securityMatcher</code>, which makes it the fallback, and it must carry the higher order number.

Example · java
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class MultiChainSecurityConfig {

    @Bean
    @Order(1)
    SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/actuator/**")
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health/**").permitAll()
                .anyRequest().hasRole("OPS"))
            .httpBasic(Customizer.withDefaults())
            // Operators call POST endpoints such as /actuator/loggers with curl, not a browser.
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable());
        return http.build();
    }

    @Bean
    @Order(2)
    SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        // No securityMatcher: this chain handles everything the first one did not.
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/error").permitAll()
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable());
        return http.build();
    }
}

Allowing static files and the H2 console in development

Spring Boot's <code>PathRequest</code> (in <code>org.springframework.boot.security.autoconfigure.web.servlet</code> in Boot 4) builds matchers for the H2 console path and the usual static locations, so you do not hard-code them. The whole class sits behind the <code>dev</code> profile: an open database console in production is a data breach waiting to happen.

Example · java
import org.springframework.boot.security.autoconfigure.web.servlet.PathRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.core.annotation.Order;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@Profile("dev")
public class DevToolsSecurityConfig {

    @Bean
    @Order(0)
    SecurityFilterChain h2Console(HttpSecurity http) throws Exception {
        http
            .securityMatcher(PathRequest.toH2Console())
            .authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
            // The console uses frames and posts forms without CSRF tokens.
            .csrf(csrf -> csrf.disable())
            .headers(headers -> headers.frameOptions(frame -> frame.sameOrigin()));
        return http.build();
    }

    @Bean
    @Order(1)
    SecurityFilterChain staticFiles(HttpSecurity http) throws Exception {
        http
            .securityMatcher(PathRequest.toStaticResources().atCommonLocations())
            .authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
        return http.build();
    }
}

A custom filter in the chain

<code>addFilterBefore</code> places your filter relative to a known security filter. The filter is created with <code>new</code> rather than declared as a <code>@Bean</code>: a <code>Filter</code> bean is also registered with the servlet container by Spring Boot and would run twice, once outside the security chain. Putting it before <code>SecurityContextHolderFilter</code> means 401 and 403 responses carry the request id too.

Example · java
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.UUID;
import org.slf4j.MDC;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.web.filter.OncePerRequestFilter;

class RequestIdFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String requestId = UUID.randomUUID().toString();
        MDC.put("requestId", requestId);
        response.setHeader("X-Request-Id", requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("requestId");
        }
    }
}

@Configuration
public class FilterOrderConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        http
            // Runs early, so even rejected requests get a request id in the logs.
            .addFilterBefore(new RequestIdFilter(), SecurityContextHolderFilter.class)
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults());
        return http.build();
    }
}

Discussion

  • Be the first to comment on this lesson.