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 approach | New 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 application | Multiple beans, one per URL namespace |
Implicit global AuthenticationManager | Explicit 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)
);
| Policy | Behaviour |
|---|---|
ALWAYS | Always create a session |
IF_REQUIRED | Create a session only if needed (default) |
NEVER | Never create, but use if one exists already |
STATELESS | Never 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
@Orderto 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
SecurityFilterChainbean with lambda DSL is the only correct approach in Spring Security 6.x —WebSecurityConfigurerAdapteris removed. - Every
HttpSecuritymethod accepts aCustomizerlambda. UseCustomizer.withDefaults()for out-of-the-box behaviour. - Multiple
@Bean @Order SecurityFilterChainbeans 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; usepermitAll()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.