The full picture
- Order matters and is documented: CORS → CSRF → auth filters (Basic/Bearer/form) → exception translation → authorization (last). Custom filters slot in with
addFilterBefore/After— knowing where is the senior part (e.g., a tenant filter after authentication, before authorization). - Multiple chains: one
SecurityFilterChainfor/api/**(stateless, JWT) and another for/admin/**(session, form login) —securityMatcherpicks per request; the first matching chain wins. - ThreadLocal implications:
SecurityContextHoldercontext doesn't follow you to@Asyncthreads (propagate viaDelegatingSecurityContextExecutor) — same class of bug as transaction ThreadLocals. - Boot's contribution: sensible default chain (everything authenticated, login form, generated password) that backs off the moment you define your own
SecurityFilterChain— same conditional model as all auto-config.
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // @PreAuthorize et al.
class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.csrf(CsrfConfigurer::disable) // stateless API
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(withDefaults())) // Bearer JWT
.build();
}
}