SecurityFilterChain Bean: The Modern Configuration API

The Modern Configuration Model

Spring Security 6.x dropped WebSecurityConfigurerAdapter. The new model uses a SecurityFilterChain @Bean directly. This is not just a syntax change — it’s a fundamentally cleaner design:

Old approachNew approach
Extend WebSecurityConfigurerAdapter@Bean SecurityFilterChain method
Override configure(HttpSecurity)Accept HttpSecurity parameter
Chain with .and()Lambda DSL — each concern is a separate block
One class per applicationMultiple beans, one per URL namespace
Implicit global AuthenticationManagerExplicit AuthenticationManager bean
// OLD — don't do this
@Configuration
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests().antMatchers("/public/**").permitAll()
            .anyRequest().authenticated().and()
            .formLogin().loginPage("/login").and()
            .logout().logoutUrl("/logout");
    }
}

// NEW — Spring Security 6.x
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form.loginPage("/login"))
            .logout(Customizer.withDefaults());
        return http.build();
    }
}

The Lambda DSL

Every HttpSecurity configuration method now accepts a lambda (or Customizer.withDefaults() for defaults). This groups related settings and removes the need for .and() to chain back:

http
    .authorizeHttpRequests(auth -> auth         // Customizer<AuthorizeHttpRequestsConfigurer>
        .requestMatchers("/public/**").permitAll()
        .anyRequest().authenticated()
    )
    .formLogin(form -> form                      // Customizer<FormLoginConfigurer>
        .loginPage("/login")
        .loginProcessingUrl("/auth/login")
        .defaultSuccessUrl("/dashboard", true)
        .failureUrl("/login?error")
    )
    .logout(logout -> logout                     // Customizer<LogoutConfigurer>
        .logoutUrl("/auth/logout")
        .logoutSuccessUrl("/login?logout")
        .invalidateHttpSession(true)
        .deleteCookies("JSESSIONID")
    )
    .sessionManagement(session -> session        // Customizer<SessionManagementConfigurer>
        .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
        .maximumSessions(1)
    )
    .exceptionHandling(ex -> ex                  // Customizer<ExceptionHandlingConfigurer>
        .authenticationEntryPoint(myEntryPoint)
        .accessDeniedHandler(myDeniedHandler)
    );

Customizer.withDefaults() applies the feature with all defaults and is equivalent to form -> {}:

.formLogin(Customizer.withDefaults())  // same as: .formLogin(form -> {})

authorizeHttpRequests — URL Security Rules

http.authorizeHttpRequests(auth -> auth
    // Public endpoints — no authentication
    .requestMatchers("/", "/index", "/about").permitAll()
    .requestMatchers("/public/**").permitAll()
    .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()

    // HTTP method specific
    .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
    .requestMatchers(HttpMethod.POST, "/api/products/**").hasRole("ADMIN")
    .requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")

    // Role and authority checks
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers("/manager/**").hasAnyRole("ADMIN", "MANAGER")
    .requestMatchers("/api/reports/**").hasAuthority("report:read")

    // Spring Security expressions (SpEL)
    .requestMatchers("/api/premium/**").access(
        new WebExpressionAuthorizationManager("hasRole('PREMIUM') or hasRole('ADMIN')")
    )

    // Actuator — restrict to local access only
    .requestMatchers("/actuator/health").permitAll()
    .requestMatchers("/actuator/**").hasRole("ACTUATOR_ADMIN")

    // Catch-all — always last
    .anyRequest().authenticated()
);

Request Matchers

// Ant pattern (most common)
.requestMatchers("/api/**")

// Specific HTTP method
.requestMatchers(HttpMethod.GET, "/products")

// Multiple patterns
.requestMatchers("/login", "/register", "/forgot-password")

// Regex (use sparingly)
.requestMatchers(new RegexRequestMatcher("/api/v[0-9]+/.*", null))

// MvcRequestMatcher — matches against @RequestMapping paths (requires MVC setup)
.requestMatchers(mvc.pattern("/api/products/{id}"))

Rule: always put more specific matchers before catch-alls. Spring evaluates rules in declaration order and uses the first match.


Session Management

http.sessionManagement(session -> session
    // No session ever created by Spring Security
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
PolicyBehaviour
ALWAYSAlways create a session
IF_REQUIREDCreate a session only if needed (default)
NEVERNever create, but use if one exists already
STATELESSNever create, never use — for JWT/REST APIs

Session Fixation Protection

http.sessionManagement(session -> session
    .sessionFixation(fixation -> fixation
        .newSession()        // new session ID + copy attributes (default) — most secure
        // .changeSessionId() // same session, new ID (lightweight alternative)
        // .migrateSession()  // new session ID + migrate attributes
        // .none()            // no protection — don't do this
    )
);

Concurrent Session Control

http.sessionManagement(session -> session
    .maximumSessions(1)          // only 1 active session per user
    .maxSessionsPreventsLogin(false)  // false: new login expires old session
                                      // true: new login rejected if limit reached
    .expiredUrl("/login?expired") // redirect when session is expired by a new login
);

CSRF Configuration

// Default (enabled) — for web apps with session
http.csrf(Customizer.withDefaults());

// Disable for stateless REST APIs (no session, no CSRF risk)
http.csrf(csrf -> csrf.disable());

// Custom token repository (e.g., cookie-based for SPAs)
http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    // withHttpOnlyFalse() allows JavaScript to read the cookie (needed for SPA)
);

// Exclude specific paths
http.csrf(csrf -> csrf
    .ignoringRequestMatchers("/api/webhooks/**")  // webhooks can't send CSRF tokens
);

CORS Configuration

// Reference a CorsConfigurationSource bean
http.cors(cors -> cors.configurationSource(corsConfigurationSource()));

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.example.com"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-CSRF-Token"));
    config.setExposedHeaders(List.of("X-Total-Count"));
    config.setAllowCredentials(true);
    config.setMaxAge(3600L);

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

Exception Handling

http.exceptionHandling(ex -> ex
    // Called when unauthenticated user accesses protected resource
    .authenticationEntryPoint((request, response, authException) -> {
        response.setStatus(HttpStatus.UNAUTHORIZED.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write("""
            {"error": "Unauthorized", "message": "%s"}
            """.formatted(authException.getMessage()));
    })

    // Called when authenticated user lacks permission
    .accessDeniedHandler((request, response, accessDeniedException) -> {
        response.setStatus(HttpStatus.FORBIDDEN.value());
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.getWriter().write("""
            {"error": "Forbidden", "message": "You don't have permission to access this resource"}
            """);
    })
);

Security Headers

http.headers(headers -> headers
    .frameOptions(frame -> frame.deny())              // X-Frame-Options: DENY
    .contentTypeOptions(Customizer.withDefaults())    // X-Content-Type-Options: nosniff
    .httpStrictTransportSecurity(hsts -> hsts
        .includeSubDomains(true)
        .maxAgeInSeconds(31536000)                    // 1 year
    )
    .contentSecurityPolicy(csp -> csp
        .policyDirectives("default-src 'self'; script-src 'self' 'nonce-{nonce}'; img-src 'self' data:")
    )
    .referrerPolicy(referrer -> referrer
        .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
    )
);

HTTP vs HTTPS

// Require HTTPS for all requests in production
http.requiresChannel(channel -> channel
    .anyRequest().requiresSecure()
);

// Or for specific paths only
http.requiresChannel(channel -> channel
    .requestMatchers("/api/**").requiresSecure()
    .anyRequest().requiresInsecure() // allow HTTP for public pages
);

Multiple SecurityFilterChain Beans

This is one of the most powerful features of the new model — different URL namespaces with completely different security configurations:

flowchart TD
    Request[HTTP Request] --> FCP[FilterChainProxy]
    FCP -->|/api/** — Order 1| Chain1[REST API Chain\nStateless · JWT · No CSRF]
    FCP -->|/actuator/** — Order 2| Chain2[Actuator Chain\nHTTP Basic · IP restriction]
    FCP -->|/** — Order 3| Chain3[Web UI Chain\nForm Login · Session · CSRF]
@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // Chain 1: REST API — Order 1 (evaluated first)
    @Bean
    @Order(1)
    public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/api/**")
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(jwtAuthEntryPoint)
            );
        return http.build();
    }

    // Chain 2: Actuator — Order 2
    @Bean
    @Order(2)
    public SecurityFilterChain actuatorChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/actuator/**")
            .httpBasic(Customizer.withDefaults())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health", "/actuator/info").permitAll()
                .anyRequest().hasRole("ACTUATOR_ADMIN")
            );
        return http.build();
    }

    // Chain 3: Web UI — Order 3 (catch-all — no securityMatcher)
    @Bean
    @Order(3)
    public SecurityFilterChain webChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**", "/login").permitAll()
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard")
            )
            .logout(Customizer.withDefaults());
        return http.build();
    }
}

Key rules for multiple chains:

  • Use @Order to control evaluation order — lower number = higher priority.
  • Use securityMatcher() on all chains except the catch-all (the lowest priority chain matches everything).
  • A request matches exactly one chain — the first chain whose matcher matches the request URL.

WebSecurity: Bypassing Security Entirely

For static resources, you might want to bypass the security filter chain entirely (not just permitAll() — completely skip the filters for performance):

@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
    return web -> web.ignoring()
        .requestMatchers("/static/**", "/favicon.ico", "/error");
}

ignoring() means these paths never enter the FilterChainProxy. permitAll() in authorizeHttpRequests still runs the full filter chain — just allows the request at the end. Use ignoring() for static files; use permitAll() for public API endpoints (so that CSRF, CORS, and logging still apply).


Complete REST API Security Configuration

A complete, production-ready configuration for a stateless JWT REST API:

@Configuration
@EnableWebSecurity
@EnableMethodSecurity   // enables @PreAuthorize, @PostAuthorize
@RequiredArgsConstructor
public class SecurityConfig {

    private final JwtAuthenticationFilter jwtAuthFilter;
    private final AppUserDetailsService userDetailsService;
    private final JwtAuthEntryPoint jwtAuthEntryPoint;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // URL authorization rules
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
            )

            // Stateless — no sessions
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))

            // No CSRF for stateless REST
            .csrf(csrf -> csrf.disable())

            // CORS — allow frontend origin
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))

            // JWT filter before username/password filter
            .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)

            // Custom error responses for API clients
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(jwtAuthEntryPoint)
                .accessDeniedHandler(jwtAccessDeniedHandler())
            )

            // Security headers
            .headers(headers -> headers
                .frameOptions(frame -> frame.deny())
                .contentSecurityPolicy(csp ->
                    csp.policyDirectives("default-src 'none'"))
            );

        return http.build();
    }

    @Bean
    public AuthenticationManager authenticationManager(
            AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public DaoAuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        provider.setUserDetailsService(userDetailsService);
        provider.setPasswordEncoder(passwordEncoder());
        return provider;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("https://app.example.com"));
        config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
        config.setAllowCredentials(true);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Bean
    public AccessDeniedHandler jwtAccessDeniedHandler() {
        return (request, response, ex) -> {
            response.setStatus(HttpStatus.FORBIDDEN.value());
            response.setContentType(MediaType.APPLICATION_JSON_VALUE);
            response.getWriter().write("{\"error\": \"Access denied\"}");
        };
    }
}

Configuration Checklist

mindmap
  root((SecurityFilterChain))
    URL Rules
      permitAll for public
      role/authority checks
      anyRequest last
    Session
      STATELESS for REST
      fixation protection
      concurrent session limit
    CSRF
      disable for stateless APIs
      enable for web apps
    CORS
      allowed origins
      allowed methods
      credentials
    Exception Handling
      authenticationEntryPoint
      accessDeniedHandler
    Headers
      frameOptions
      contentSecurityPolicy
      HSTS
    Filters
      JWT filter placement
      custom filters

Summary

  • The SecurityFilterChain bean with lambda DSL is the only correct approach in Spring Security 6.x — WebSecurityConfigurerAdapter is removed.
  • Every HttpSecurity method accepts a Customizer lambda. Use Customizer.withDefaults() for out-of-the-box behaviour.
  • Multiple @Bean @Order SecurityFilterChain beans support different security models for different URL namespaces.
  • securityMatcher() restricts which requests a chain applies to; omit it on the lowest-priority catch-all chain.
  • Use webSecurityCustomizer().ignoring() to skip the filter chain entirely for static resources; use permitAll() for public API endpoints that still need CORS/CSRF/logging.

Next: Article 6 covers form login authentication in depth — the full request flow from login form to session cookie, custom success/failure handlers, and remember-me integration.