Spring Security Custom Filters: The Complete Guide (Spring Boot 3)
Spring Security’s defaults are great—until they aren’t. Eventually, you’ll need to do something weird.
Maybe you need to check a custom X-Tenant-ID header. Maybe you’re validating a legacy API key. Or maybe you just want to log every request body before authentication happens.
At www.codegigs.app, custom filters are the #1 way we solve edge-case requirements for our enterprise students. Here is exactly how to write one that doesn’t break your application.
The Golden Rule: Use `OncePerRequestFilter`
You could implement the raw Jakarta Filter interface. Please don’t.
If you use a raw filter, it might run multiple times per request (e.g., once for the request, once for the forward, once for the error dispatch). This leads to weird bugs where your auth logic runs twice.
Spring provides OncePerRequestFilter. It does exactly what it says on the tin. Use it.
Scenario: Validating a Custom API Key
Let’s build a filter that checks for a header named X-API-KEY. If it’s missing or invalid, we reject the request immediately.
java
// ApiKeyAuthFilter.java
// Spring Boot 3.2+
package app.codegigs.security;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class ApiKeyAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 1. Check for the header
String apiKey = request.getHeader("X-API-KEY");
// 2. Validate it (In reality, check DB or Redis)
if ("secret-key-123".equals(apiKey)) {
// Valid! Continue the chain
filterChain.doFilter(request, response);
} else {
// Invalid! Block the request
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("Invalid API Key");
// DO NOT call filterChain.doFilter() here
}
}
}
The Trap: Notice line 31? If the validation fails, we write the response and STOP. We do not call filterChain.doFilter(). If you call it, the request continues to the controller even though you wanted to block it.
Where Do I Put It? (Filter Ordering)
Writing the filter is easy. Putting it in the right spot is where people mess up.
Spring Security is just a big list of filters. If you put your custom filter after the authentication filter, your custom logic might never run (because the user got rejected by the standard auth first).
For API keys or JWTs, you usually want to run before the UsernamePasswordAuthenticationFilter.
Java
// SecurityConfig.java
@Configuration
@EnableWebSecurity
public class SecurityConfig {
private final ApiKeyAuthFilter apiKeyAuthFilter;
public SecurityConfig(ApiKeyAuthFilter apiKeyAuthFilter) {
this.apiKeyAuthFilter = apiKeyAuthFilter;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.anyRequest().authenticated()
)
// HERE IS THE MAGIC
.addFilterBefore(apiKeyAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
Common positions:
addFilterBefore(..., UsernamePasswordAuthenticationFilter.class): Best for custom auth tokens (JWT, API Keys).addFilterAfter(..., ExceptionTranslationFilter.class): Good for logging or auditing successful requests.addFilterAt(...): Replaces a standard filter (advanced use only).
Validating Authenticated Users (The Context Holder)
If your filter successfully authenticates a user (like verifying a JWT), you need to tell Spring Security about it. You do this by updating the SecurityContextHolder.
If you don’t do this, the next filter in the chain will say “Who is this?” and throw a 403.
// Inside doFilterInternal...
if (isValid(token)) {
// Create an Authentication object
UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(
userString,
null,
List.of(new SimpleGrantedAuthority("ROLE_USER"))
);
// TELL SPRING WE ARE LOGGED IN
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
This is the core logic behind our JWT Authentication guide. Once the context is set, Spring assumes the user is logged in.
private String currentUser). It will be overwritten by concurrent requests. Use local variables inside doFilterInternal only.
Common Mistakes I See
- Forgetting to Authenticate: You check the token but forget to update
SecurityContextHolder. Result: 403 Forbidden. - Double Execution: You annotated your filter with
@ComponentAND manually added it withaddFilterBefore. In some Spring Boot versions, this causes the filter to register twice (once in the default chain, once in the security chain). If your logic runs twice, remove@Componentand just instantiate it in the config class. - Exceptions in Filters: If your filter throws an exception, the global
@ControllerAdvicewon’t catch it because the request hasn’t reached the DispatcherServlet yet. You need to handle errors inside the filter or use anAuthenticationEntryPoint.
Filters Are Just the Beginning
Once you master filters, you control the entire security stack.
We build a complete multi-tenant security system using custom filters in the full course.
Summary
- Extend
OncePerRequestFilter, notFilter. - Use
addFilterBeforeto run your logic before standard authentication. - Update
SecurityContextHolderif your filter performs authentication.
Ready to apply this to OAuth2? Check out our next guide on Spring Security OAuth2.