Skip to content

Recipe: Bring Your Own SecurityFilterChain

Problem: your app already has a SecurityFilterChain (or needs one IAM’s defaults can’t express). Two competing chains — or a library that force-feeds you one — is not an option.

IAM’s own chain is declared @ConditionalOnMissingBean(SecurityFilterChain::class). The moment your configuration defines any SecurityFilterChain bean, IAM contributes none — reliably, because auto-configuration is processed after host configuration. The building blocks stay exposed as beans for you to reuse.

Define your chain, reusing IAM’s JWT filter

Section titled “Define your chain, reusing IAM’s JWT filter”
@Configuration
@EnableWebSecurity
class HostSecurityConfig(
// IAM's filter bean — validates Bearer tokens and populates the SecurityContext,
// which is what @RequiresPermission enforcement reads.
private val iamJwtFilter: JwtAuthenticationFilter,
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.csrf { it.disable() }
.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
.authorizeHttpRequests { auth ->
auth
// Permit the ERROR dispatch (NOT the /error path) FIRST — otherwise a controller
// throwing ResponseStatusException(404/409/…) re-enters this chain via the
// container's error dispatch unauthenticated and surfaces as a misleading 401.
.dispatcherTypeMatchers(DispatcherType.ERROR).permitAll()
// IAM's endpoints your chain must keep reachable:
.requestMatchers("/api/iam/v1/auth/**").permitAll() // login/refresh
.requestMatchers(HttpMethod.GET, "/.well-known/jwks.json").permitAll()
// your own rules:
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
}
.addFilterBefore(iamJwtFilter, UsernamePasswordAuthenticationFilter::class.java)
return http.build()
}
}
  • Full ownership of the chain — matchers, CSRF/CORS posture, extra filters, anything.
  • IAM authentication still works: the reused JwtAuthenticationFilter populates the SecurityContext, so @RequiresPermission, @FieldFiltered, and PermissionChecker behave identically.
  • No double-chain surprises: IAM’s chain simply never exists.

Replacing the chain is the last resort. In order of increasing force:

need use
open a few extra paths iam.security.additional-permit-all: ["/health/**", ...]
adjust IAM’s chain (add a filter, tweak a matcher) IamSecurityCustomizer SPI — runs against IAM’s HttpSecurity before it builds
different chain entirely this recipe
@Component
class MetricsExposure : IamSecurityCustomizer {
override fun customize(http: HttpSecurity) {
http.authorizeHttpRequests { it.requestMatchers("/actuator/prometheus").permitAll() }
}
}