Adding a Custom Filter to Spring Security Filter Chain: A Complete Guide
Learn how to add a custom filter to the Spring Security filter chain with production-tested patterns.
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
- ✓Java 17+
- ✓Spring Boot 3.x
- ✓Basic understanding of Spring Security configuration
- ✓Familiarity with servlet filters
- Use
addFilterBefore()oraddFilterAfter()to insert your filter at a specific position in the chain. - Extend
OncePerRequestFilterto ensure your filter executes once per request. - Never call
FilterChain.doFilter()more than once or forget to call it. - Register your filter as a Spring bean to enable dependency injection.
- For reactive apps, use
WebFilterinstead ofjavax.servlet.Filter.
Think of the Spring Security filter chain as a series of security checkpoints at an airport. Each checkpoint performs a specific check (e.g., ID verification, baggage scan). Adding a custom filter is like adding a new checkpoint—say, a pat-down—at a specific point in the line. You can place it before or after existing checkpoints. The key is to not skip any checkpoints and to ensure your checkpoint doesn't hold up the line indefinitely.
If you've worked with Spring Security for more than a week, you've probably needed to add custom logic to the security filter chain. Maybe you need to extract a tenant ID from a header for multi-tenancy, validate a custom token format, or log every authentication attempt for auditing. The filter chain is the perfect place for such cross-cutting concerns—but only if you know how to hook into it correctly.
I've seen countless production incidents caused by misconfigured filters: filters that fire twice, filters that bypass authentication entirely, filters that throw obscure NullPointerExceptions because they weren't initialized as Spring beans. In this guide, I'll show you the right way to add a custom filter, the traps to avoid, and how to debug when things go wrong.
We'll cover everything from the basics of the filter chain to advanced patterns like conditional filtering, ordering, and testing. By the end, you'll be able to add custom filters with confidence—and maybe even fix a production issue at 2 AM without breaking a sweat.
Understanding the Spring Security Filter Chain
Before you add a custom filter, you need to understand how the filter chain works. Spring Security is built on a chain of servlet filters, each responsible for a specific security concern: authentication, authorization, CSRF protection, session management, and more. When a request comes in, it passes through these filters in a specific order.
The key classes are FilterChainProxy and SecurityFilterChain. FilterChainProxy delegates to one or more SecurityFilterChain instances based on the request. Each SecurityFilterChain contains an ordered list of filters. Spring Boot auto-configures a default chain, but you can customize it by defining a SecurityFilterChain bean.
- SecurityContextPersistenceFilter
- LogoutFilter
- UsernamePasswordAuthenticationFilter
- DefaultLoginPageGeneratingFilter
- BasicAuthenticationFilter
- ExceptionTranslationFilter
- FilterSecurityInterceptor
Your custom filter can be inserted before, after, or at the position of any of these filters using addFilterBefore(), addFilterAfter(), or addFilterAt(). The last one replaces an existing filter at that position, which is risky if you don't know the exact behavior of the original filter.
Production Insight: Never use addFilterAt() unless you are 100% sure you want to replace an existing filter. I've seen teams replace the SecurityContextPersistenceFilter and then wonder why the security context is empty in subsequent filters. If you need to run logic around an existing filter, use addFilterBefore() or addFilterAfter() instead.
SecurityContextHolderFilter. Always check the actual filter order by enabling debug logging: logging.level.org.springframework.security=DEBUG. This will print the filter chain on startup.addFilterBefore() or addFilterAfter() with a known filter class as a reference point.Creating a Custom Filter: OncePerRequestFilter vs Filter
The most important decision when creating a custom security filter is whether to implement javax.servlet.Filter or extend OncePerRequestFilter. In 99% of cases, you should choose OncePerRequestFilter. Here's why:
OncePerRequestFilterguarantees that the filter executes only once per request, even if the request is forwarded or included. StandardFiltercan execute multiple times if the request is dispatched to another resource.OncePerRequestFilterprovides a convenientdoFilterInternal()method that already handles theFilterChaincall, so you can't forget to call it (unless you overridedoFilter()directly).- It integrates nicely with Spring's
GenericFilterBean, giving you access to initialization parameters and Spring bean lifecycle.
Here's a template for a custom filter that extracts a tenant ID from a header:
```java @Component public class TenantFilter 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) { TenantContext.setCurrentTenant(tenantId); } try { filterChain.doFilter(request, response); } finally { TenantContext.clear(); } } } ```
Notice the try-finally block. This ensures the tenant context is cleared even if an exception occurs downstream. This pattern is critical for thread-safe cleanup, especially in environments with thread pooling.
Production Insight: I once saw a filter that set a request-scoped attribute but forgot to clear it. In a high-throughput system, this caused memory leaks and eventually OutOfMemoryErrors. Always clean up thread-local or request-scoped data in a finally block.
shouldNotFilter() method. This is cleaner than checking the path inside doFilterInternal().OncePerRequestFilter for single-execution guarantee and cleaner code. Always clean up resources in a finally block.Registering Your Filter in the Security Filter Chain
Once you have your filter class, you need to register it in the security configuration. There are two common approaches: manual registration in a SecurityFilterChain bean, or using @Component with auto-detection. I strongly recommend manual registration for clarity and control.
Approach 1: Manual Registration (Preferred)
```java @Configuration @EnableWebSecurity public class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authz -> authz .requestMatchers("/public/**").permitAll() .anyRequest().authenticated() ) .addFilterBefore(new TenantFilter(), BasicAuthenticationFilter.class); return http.build(); } } ```
Approach 2: Using @Component (Risky)
If you annotate your filter with @Component, Spring Boot will automatically register it as a servlet filter in the container's filter chain. But this is outside the Spring Security filter chain. The filter will still execute, but it won't have access to the security context, and it may execute at a different point in the request lifecycle. This can lead to subtle bugs.
Worse, if you also manually add the filter in SecurityFilterChain, it will execute twice—once as a container filter and once as a security filter. This is exactly what caused the production incident I described earlier.
Best Practice: If you need the filter to be part of the security chain, do not annotate it with @Component. Instead, create the filter instance manually or inject it as a bean but register it only in the security config.
```java @Bean public TenantFilter tenantFilter() { return new TenantFilter(); }
@Bean public SecurityFilterChain filterChain(HttpSecurity http, TenantFilter tenantFilter) throws Exception { http.addFilterBefore(tenantFilter, BasicAuthenticationFilter.class); return http.build(); } ```
This way, the filter is a Spring bean (so it can have dependencies injected), but it's only registered in the security chain.
Ordering Your Filter Correctly
The position of your filter in the chain determines what information is available and how it interacts with other filters. Spring Security provides several well-known filter classes that you can use as reference points:
SecurityContextPersistenceFilter(orSecurityContextHolderFilterin Boot 3.1+): Ensures the SecurityContext is loaded from the session. Place filters that need the authenticated user after this.UsernamePasswordAuthenticationFilter: Processes form logins. Place filters that need to inspect login attempts before this.BasicAuthenticationFilter: Processes basic auth. Place filters that need to validate tokens after this if you want to use the authenticated principal.ExceptionTranslationFilter: Handles authentication exceptions. Place filters that might throw auth exceptions before this so they are properly translated.FilterSecurityInterceptor: The last filter that enforces authorization. Place filters that need to run after authorization checks (e.g., auditing) before this.
Here's how to add a filter after the security context is established:
``java http.addFilterAfter(new ``AuditFilter(), SecurityContextHolderFilter.class);
Production Insight: In Spring Boot 3.1, the SecurityContextPersistenceFilter was replaced by SecurityContextHolderFilter. If you're upgrading, your addFilterAfter calls referencing the old class will break silently—the filter will be placed at the end of the chain, not where you intended. Always check the actual filter class names by enabling debug logging.
What the Official Docs Won't Tell You
I've spent years debugging Spring Security filter issues, and here are the gotchas that the official documentation glosses over:
1. The @Component Trap
As mentioned, annotating your filter with @Component registers it as a servlet filter in the container, not in the Spring Security chain. But here's the subtle part: if you also have a SecurityFilterChain bean, the container filter will still execute, but it will run before the security chain. This means your filter might see unauthenticated requests even though the security chain eventually authenticates them. This can cause inconsistent behavior, especially if your filter relies on the security context.
2. addFilterAt() is a Landmine
The addFilterAt() method replaces an existing filter at the specified position. But if the filter class you specify is not in the chain (e.g., because of a configuration change), your filter will be added at the end without warning. This can lead to filters executing in the wrong order. I've seen this happen when upgrading Spring Boot versions where a filter class was renamed.
3. Request Wrapping Pitfalls
If you wrap the request (e.g., to cache the body), you must ensure that the wrapper is passed to filterChain.doFilter(). Many developers forget this and pass the original request, causing the wrapper to be ignored. Also, if you wrap the response, be aware that downstream filters might call getWriter() or getOutputStream() multiple times, which can cause exceptions if your wrapper doesn't support it.
4. Async Requests
OncePerRequestFilter does not guarantee single execution for async requests by default. You need to set isAsyncDispatch() to true if you want the filter to execute on the async dispatch as well. Otherwise, it will only run on the initial request thread.
5. Testing Filters Requires Mock Requests
Unit testing filters is straightforward with MockHttpServletRequest and MockHttpServletResponse. But integration testing with @WebMvcTest can be tricky because the full security chain is not loaded. Use @SpringBootTest with a mock web environment or @AutoConfigureMockMvc to test the entire chain.
SecurityContextHolderFilter is the new default. If you have custom filters referencing SecurityContextPersistenceFilter, they will silently fail to order correctly. Always check the filter chain dump on startup.Conditional Filter Execution with shouldNotFilter
Often you don't want your filter to run on every request. For example, a tenant filter might be unnecessary for public endpoints. OncePerRequestFilter provides a convenient shouldNotFilter() method that you can override to skip execution based on the request.
``java @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String path = request.getRequestU``RI(); return path.startsWith("/public/") || path.startsWith("/actuator/"); }
This is much cleaner than checking the path inside doFilterInternal() and returning early. It also makes the filter's intent clearer.
Production Insight: Be careful with path matching. If you use request.getRequestU, it includes the context path. Use RI()request.getServletPath() if you want the path relative to the context. Also, consider using AntPathMatcher or PathPattern for more complex patterns.
You can also make the exclusion paths configurable via properties:
```java @Value("${app.filter.skip-paths:/public/,/actuator/}") private List<String> skipPaths;
@Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { String path = request.getServletPath(); return skipPaths.stream().anyMatch(pattern -> pathMatch(pattern, path)); } ```
This allows you to change the behavior without recompiling.
@Value in a filter, ensure the filter is a Spring bean. If you instantiate it with new, the @Value fields will be null. Always inject the filter as a bean or use constructor injection.shouldNotFilter() to conditionally skip filter execution. Make exclusion paths configurable for flexibility in different environments.Testing Custom Filters
Testing custom filters is essential to ensure they work correctly in the security chain. You should write both unit tests and integration tests.
Unit Testing
Unit tests verify the filter logic in isolation. Use MockHttpServletRequest and MockHttpServletResponse to simulate requests.
``java @Test void testTenantFilterSetsContext() throws Exception { TenantFilter filter = new ``TenantFilter(); MockHttpServletRequest request = new MockHttpServletRequest(); request.addHeader("X-Tenant-ID", "tenant123"); MockHttpServletResponse response = new MockHttpServletResponse(); FilterChain filterChain = (req, res) -> { assertEquals("tenant123", TenantContext.getCurrentTenant()); }; filter.doFilterInternal(request, response, filterChain); assertNull(TenantContext.getCurrentTenant()); // cleared after }
Integration Testing
Integration tests verify that the filter is correctly registered in the security chain. Use @SpringBootTest with TestRestTemplate or MockMvc.
```java @SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT) @AutoConfigureMockMvc class SecurityFilterIntegrationTest {
@Autowired private MockMvc mockMvc;
@Test void testFilterIsApplied() throws Exception { mockMvc.perform(get("/api/secure") .header("X-Tenant-ID", "tenant123")) .andExpect(status().isOk()); // Verify tenant context was set (e.g., via a test endpoint that returns the tenant) } } ```
Production Insight: When testing with @WebMvcTest, the security auto-configuration is not loaded. Use @SpringBootTest to test the full security chain. Also, be aware that MockMvc does not actually send HTTP requests, so filters that rely on request attributes set by the container may not work as expected.
TestRestTemplate with a real server for end-to-end testing.The Double-Filter Disaster at a Fintech Startup
- Always extend OncePerRequestFilter for custom security filters.
- Avoid mixing automatic detection (@Component) with manual registration.
- Use logging at the start and end of your filter to trace execution count.
- Test filter invocation count in integration tests with MockHttpServletRequest.
- Document your filter registration approach in your team's security guidelines.
forward() or include() calls that might re-trigger the filter.curl -v http://localhost:8080/api/testgrep 'CustomFilter' /var/log/app.log| File | Command / Code | Purpose |
|---|---|---|
| SecurityConfig.java | @Configuration | Understanding the Spring Security Filter Chain |
| TenantFilter.java | @Component | Creating a Custom Filter |
| SecurityConfigOrder.java | http | Ordering Your Filter Correctly |
| RequestCachingFilter.java | public class RequestCachingFilter extends OncePerRequestFilter { | What the Official Docs Won't Tell You |
| ConfigurableTenantFilter.java | @Component | Conditional Filter Execution with shouldNotFilter |
| TenantFilterTest.java | class TenantFilterTest { | Testing Custom Filters |
Key takeaways
Interview Questions on This Topic
Explain how to add a custom filter to the Spring Security filter chain. What are the key methods and considerations?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Notes here come from systems that actually shipped.
That's Spring Security. Mark it forged?
7 min read · try the examples if you haven't