Skip to main content
spring internals

DelegatingFilterProxy, FilterChainProxy, and the Servlet Integration

7 min read Chapter 32 of 78

DelegatingFilterProxy, FilterChainProxy, and the Servlet Integration

The servlet container and the Spring application context are two separate worlds. The servlet container manages jakarta.servlet.Filter instances through its own lifecycle. The Spring container manages beans through dependency injection. Spring Security lives in the Spring container, but it must intercept requests at the servlet filter level. The bridge between these two worlds is org.springframework.web.filter.DelegatingFilterProxy.

DelegatingFilterProxy: The Servlet Container Hook

DelegatingFilterProxy is a standard servlet filter registered in the servlet container. In Spring Boot, this registration happens automatically through org.springframework.boot.web.servlet.DelegatingFilterProxyRegistrationBean, which is created by org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration.

The filter does one thing: on every request, it looks up a Spring bean by name from the WebApplicationContext and calls doFilter() on that bean. The bean name it looks for is springSecurityFilterChain.

Here is what matters about DelegatingFilterProxy:

  • It is a servlet filter. The servlet container owns its lifecycle.
  • It is lazy. It does not look up the target bean at initialization time. It waits until the first request. This avoids startup ordering issues where the ApplicationContext has not finished initializing when the servlet container registers filters.
  • It does not know anything about security. It is a generic delegation mechanism that Spring Web provides. Spring Security just happens to use it.
Tomcat (servlet container)

  ├─ CharacterEncodingFilter
  ├─ DelegatingFilterProxy (targetBeanName = "springSecurityFilterChain")
  │     └─ looks up bean → FilterChainProxy
  └─ DispatcherServlet

You can verify this registration at startup with TRACE logging on org.springframework.boot.web.servlet:

TRACE ServletContextInitializerBeans - Added existing Servlet Filter
  initializer bean 'springSecurityFilterChainRegistration';
  order=DEFAULT_ORDER

FilterChainProxy: The Dispatcher

org.springframework.security.web.FilterChainProxy is the Spring bean named springSecurityFilterChain. It holds a List<SecurityFilterChain> and implements jakarta.servlet.Filter.

When doFilter() is called:

  1. It iterates through its SecurityFilterChain list in order.
  2. For each chain, it calls chain.matches(request).
  3. The first chain that returns true wins. Its filters are used for this request.
  4. If no chain matches, the request passes through with no security filtering.

The source of FilterChainProxy.doFilterInternal() makes this explicit:

// Simplified from the actual Spring Security source
private void doFilterInternal(ServletRequest request, ServletResponse response,
        FilterChain chain) throws IOException, ServletException {

    HttpServletRequest httpRequest = (HttpServletRequest) request;

    List<Filter> filters = getFilters(httpRequest);

    if (filters == null || filters.isEmpty()) {
        chain.doFilter(request, response);
        return;
    }

    VirtualFilterChain virtualFilterChain =
        new VirtualFilterChain(httpRequest, chain, filters);
    virtualFilterChain.doFilter(request, response);
}

private List<Filter> getFilters(HttpServletRequest request) {
    for (SecurityFilterChain chain : this.filterChains) {
        if (chain.matches(request)) {
            return chain.getFilters();
        }
    }
    return null;
}

The VirtualFilterChain is an internal class that walks through the matched chain’s filters one by one, then continues to the original servlet filter chain (which eventually reaches DispatcherServlet).

Request Matching in Detail

Each SecurityFilterChain holds a RequestMatcher. When you call http.securityMatcher("/api/**") in your configuration, Spring Security creates a org.springframework.security.web.util.matcher.AntPathRequestMatcher (or MvcRequestMatcher when Spring MVC is on the classpath) and stores it in the chain.

A chain with no securityMatcher() call uses org.springframework.security.web.util.matcher.AnyRequestMatcher.INSTANCE, which matches everything.

// SecurityFilterChain interface
public interface SecurityFilterChain {
    boolean matches(HttpServletRequest request);
    List<Filter> getFilters();
}

The implementation org.springframework.security.web.DefaultSecurityFilterChain delegates matches() to its RequestMatcher:

public final class DefaultSecurityFilterChain implements SecurityFilterChain {

    private final RequestMatcher requestMatcher;
    private final List<Filter> filters;

    @Override
    public boolean matches(HttpServletRequest request) {
        return this.requestMatcher.matches(request);
    }

    @Override
    public List<Filter> getFilters() {
        return this.filters;
    }
}

The springSecurityFilterChain Bean Name

The name springSecurityFilterChain is not arbitrary. It is defined as a constant in org.springframework.security.config.BeanIds.SPRING_SECURITY_FILTER_CHAIN. The WebSecurityConfiguration class creates the FilterChainProxy bean under this name. The DelegatingFilterProxy looks up the bean by this exact name.

If you have ever seen the error:

No bean named 'springSecurityFilterChain' is defined

it means @EnableWebSecurity is not active, or it is being processed after the DelegatingFilterProxy tried to look up the bean. In Spring Boot 3, this is rare because org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration handles it. But in manual configurations without Spring Boot, this is a real trap.

Tracing a Request Through the SaaS Backend

Here is the complete path for a GET /api/tenants request to the multi-tenant SaaS backend:

1. Tomcat receives the HTTP request
2. Tomcat calls its filter chain:
   a. CharacterEncodingFilter.doFilter()
   b. DelegatingFilterProxy.doFilter()
      - Looks up "springSecurityFilterChain" bean → gets FilterChainProxy
      - Calls FilterChainProxy.doFilter()
3. FilterChainProxy.doFilterInternal()
   - Checks SecurityFilterChain #0: matches("/actuator/health") → false
   - Checks SecurityFilterChain #1: matches("/api/**") → true
   - Gets filter list from chain #1
   - Creates VirtualFilterChain with those filters
4. VirtualFilterChain.doFilter() walks through:
   - DisableEncodeUrlFilter
   - SecurityContextHolderFilter
   - HeaderWriterFilter
   - LogoutFilter (no-op, not a logout request)
   - BearerTokenAuthenticationFilter (extracts JWT, authenticates)
   - RequestCacheAwareFilter
   - SecurityContextHolderAwareRequestFilter
   - SessionManagementFilter
   - ExceptionTranslationFilter
   - AuthorizationFilter (checks authorization)
5. Original servlet filter chain continues
6. DispatcherServlet receives the request
7. Controller method executes

Steps 3 and 4 are where all security decisions happen. If you set logging.level.org.springframework.security.web.FilterChainProxy=DEBUG, every step from 3 onward is logged.

The Failure Mode: Bypassing Spring Security

// BROKEN: registering a tenant validation filter directly in the servlet container

@Component
public class TenantValidationFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String tenantId = httpRequest.getHeader("X-Tenant-ID");
        if (tenantId == null || tenantId.isBlank()) {
            HttpServletResponse httpResponse = (HttpServletResponse) response;
            httpResponse.sendError(HttpServletResponse.SC_BAD_REQUEST,
                "X-Tenant-ID header required");
            return;
        }
        chain.doFilter(request, response);
    }
}

When you annotate a Filter with @Component, Spring Boot registers it directly in the servlet container via FilterRegistrationBean auto-detection. This filter runs outside the SecurityFilterChain. It runs before or after DelegatingFilterProxy depending on its order, but it is never part of the security chain.

The problems:

  • The filter cannot access SecurityContextHolder reliably because it may run before SecurityContextHolderFilter.
  • It cannot use Spring Security’s exception handling. A 403 thrown here is not processed by ExceptionTranslationFilter.
  • It runs on every request, including ones that should be unauthenticated (health checks, public pages).
  • It cannot be configured per SecurityFilterChain. It applies globally.

The Correct Pattern: Adding Filters Through HttpSecurity

// CORRECT: registering the tenant filter inside the SecurityFilterChain

public class TenantValidationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        String tenantId = request.getHeader("X-Tenant-ID");
        if (tenantId == null || tenantId.isBlank()) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST,
                "X-Tenant-ID header required");
            return;
        }
        filterChain.doFilter(request, response);
    }
}

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    @Order(1)
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .addFilterAfter(new TenantValidationFilter(),
                BearerTokenAuthenticationFilter.class)
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .csrf(csrf -> csrf.disable())
            .build();
    }

    @Bean
    @Order(2)
    public SecurityFilterChain adminFilterChain(HttpSecurity http) throws Exception {
        // No TenantValidationFilter here. Admin panel is not tenant-scoped.
        return http
            .securityMatcher("/admin/**")
            .authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ADMIN"))
            .formLogin(form -> form.loginPage("/admin/login").permitAll())
            .csrf(Customizer.withDefaults())
            .build();
    }
}

Key differences:

  • TenantValidationFilter extends org.springframework.web.filter.OncePerRequestFilter instead of raw Filter. This prevents double execution on forwarded requests.
  • It is not annotated with @Component. It is not a Spring bean. It is instantiated directly and added to a specific chain.
  • addFilterAfter(filter, BearerTokenAuthenticationFilter.class) places it after JWT authentication. The tenant ID is validated only after the caller is authenticated.
  • It only runs for /api/** requests. The admin chain does not include it.

If you need the filter to be a Spring bean (for dependency injection), create it as a @Bean but also register a FilterRegistrationBean with setEnabled(false) to prevent servlet container auto-registration:

@Bean
public TenantValidationFilter tenantValidationFilter(TenantService tenantService) {
    return new TenantValidationFilter(tenantService);
}

@Bean
public FilterRegistrationBean<TenantValidationFilter> tenantFilterRegistration(
        TenantValidationFilter filter) {
    FilterRegistrationBean<TenantValidationFilter> registration =
        new FilterRegistrationBean<>(filter);
    registration.setEnabled(false); // Prevent servlet container registration
    return registration;
}

Then inject it into your SecurityFilterChain configuration:

@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http,
        TenantValidationFilter tenantFilter) throws Exception {
    return http
        .securityMatcher("/api/**")
        .addFilterAfter(tenantFilter, BearerTokenAuthenticationFilter.class)
        // ...
        .build();
}

This keeps the filter inside the security chain, with full access to SecurityContextHolder, processed by ExceptionTranslationFilter, and scoped to exactly the chains that need it.