Configuring Security with SecurityFilterChain
Spring Security 6 (bundled with Spring Boot 3) retired WebSecurityConfigurerAdapter entirely. The old approach required you to extend an abstract class and override a handful of protected methods — a rigid design that forced all security config into one inheritance hierarchy. The modern approach is purely component-based: you declare a @Bean of type SecurityFilterChain inside any @Configuration class and configure it with a fluent lambda DSL. This lesson walks through that pattern, explains every major knob, and shows you why the new design is both more readable and more testable.
Why WebSecurityConfigurerAdapter Was Removed
Before Spring Security 5.7, the canonical way to configure security was:
// OLD — do NOT use this pattern in Spring Boot 3
@Configuration
public class OldSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/public/**").permitAll()
.anyRequest().authenticated()
.and()
.formLogin();
}
}
The problems were real: subclassing made it impossible to have more than one configuration class contribute partial rules, the and() chaining was error-prone, and the inheritance dependency made unit-testing the class difficult. The new design removes all of that.
The Minimal Modern Configuration
All you need is a plain @Configuration class with one @Bean method:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**", "/actuator/health").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
.permitAll()
);
return http.build();
}
}
Each call to http.authorizeHttpRequests(), formLogin(), logout() etc. now accepts a lambda configurator — a Consumer<T> that receives a typed DSL object. This eliminates the and() calls and makes every section self-contained and readable.
@EnableWebSecurity is technically optional when you have Spring Boot's auto-configuration on the classpath, because Boot applies it for you. Declaring it explicitly is good practice: it makes the intent clear, enables debug-level security logging, and ensures the configuration is picked up even if you refactor away from a Boot-managed context.
Authorization Rules — Order Matters
Rules inside authorizeHttpRequests are evaluated top to bottom, and the first matching rule wins. Always put your most specific patterns first:
.authorizeHttpRequests(auth -> auth
// 1. Public assets and login endpoint — no auth required
.requestMatchers("/css/**", "/js/**", "/images/**").permitAll()
.requestMatchers("/login", "/register", "/forgot-password").permitAll()
// 2. Role-specific areas
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/internal/**").hasAnyRole("ADMIN", "SYSTEM")
// 3. Any authenticated user can reach the rest
.anyRequest().authenticated()
)
Putting .anyRequest().authenticated() before more specific rules will shadow them. A rule placed after anyRequest() is silently ignored. Always make anyRequest() the last entry in the block.
Disabling CSRF for REST APIs
CSRF protection is enabled by default because Spring Security's default posture assumes a browser-based session application. For a stateless REST API that uses token-based authentication (e.g., JWT), CSRF is unnecessary and should be disabled:
import org.springframework.security.config.http.SessionCreationPolicy;
@Bean
public SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
SessionCreationPolicy.STATELESS tells Spring Security never to create or consult an HttpSession. Combined with disabled CSRF, this is the correct baseline for any JWT-secured microservice.
Never disable CSRF for a traditional server-rendered web application. CSRF tokens protect session-cookie-based flows. Only disable CSRF when you have verified that your authentication mechanism is not vulnerable (e.g., Bearer tokens that cannot be sent by third-party sites without JavaScript cooperation).
Multiple SecurityFilterChain Beans
One of the biggest wins of the component-based model is that you can have multiple SecurityFilterChain beans in the same application, each guarding a different URL namespace with different rules. Use @Order to control which chain is evaluated first:
import org.springframework.core.annotation.Order;
@Configuration
@EnableWebSecurity
public class MultiChainSecurityConfig {
// Chain 1 — stateless API under /api/**
@Bean
@Order(1)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**") // scope this chain to /api/**
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
// Chain 2 — traditional form-login web UI
@Bean
@Order(2)
public SecurityFilterChain webChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/about", "/login").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form.loginPage("/login").permitAll())
.logout(logout -> logout.permitAll());
return http.build();
}
}
The key is securityMatcher() — it limits which requests a chain handles. A chain without a securityMatcher acts as a catch-all and should have the highest order number (lowest priority).
Wiring a Custom UserDetailsService and PasswordEncoder
The SecurityFilterChain bean does not wire authentication directly — that is the job of an AuthenticationManager backed by a UserDetailsService and a PasswordEncoder. Declare them as separate beans in the same (or any) @Configuration class:
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public UserDetailsService userDetailsService(UserRepository userRepo) {
return username -> userRepo.findByUsername(username)
.map(AppUserDetails::new) // adapt your entity to UserDetails
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
}
Spring Security auto-detects these beans and wires them into the default AuthenticationManager. There is no need to override or register them manually.
Summary
The modern SecurityFilterChain bean pattern is cleaner, more composable, and fully testable. Replace any legacy WebSecurityConfigurerAdapter subclass with a @Configuration class containing one or more @Bean SecurityFilterChain methods. Use the lambda DSL to configure authorization rules, CSRF policy, session management, and login/logout flows in self-contained sections. When you need different security rules for different parts of your application — for example, a stateless API alongside a traditional web UI — declare multiple chains with explicit @Order and securityMatcher() scoping. In the next lesson you will implement UserDetailsService and integrate it with a real database-backed user store.