A filter is the first thing to touch a request. There’s no DispatcherServlet, no controller, no Spring bean yet, because a filter runs at the servlet container (Tomcat and friends) level. That position defines what a filter is for, and it explains why CORS handling and security token validation live in filters rather than interceptors.
Servlet
A Java server-side component that processes client HTTP requests and generates responses. It’s a core technology of the Java EE (now Jakarta EE) standard and the foundation of most Java web frameworks.
What a filter catches
A filter’s scope is every request. Not just requests headed to a controller, but static resource requests and requests for bad paths that end in an error, all pass through the filter, because it sits in front of the DispatcherServlet. Work that must apply to every request without exception belongs to filters: unifying character encoding, CORS headers, security token validation, response compression, and request/response logging.
A filter works with the raw ServletRequest/ServletResponse. It can read the request body, wrap the response stream to transform it, or swap out the objects handed to the next stage entirely. Spring concepts like controllers and ModelAndView aren’t visible; you handle HTTP requests and responses in their primitive form.
doFilter
A filter’s body is the single doFilter method. The filterChain.doFilter(request, response) call is the dividing line: above it is preprocessing, below it is postprocessing.
public class CustomFilter extends GenericFilterBean {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
throws IOException, ServletException {
// preprocessing: before moving to the next stage
filterChain.doFilter(request, response); // to the next filter or the servlet
// postprocessing: after the response comes back
}
}
GenericFilterBean is Spring’s abstract class implementing javax.servlet.Filter, integrated with Spring configuration. In practice you usually use OncePerRequestFilter. Even if a forward or include inside the servlet container sends the same request through the chain multiple times, it guarantees your filter logic runs exactly once per request. That’s essential for things like authentication and logging that must happen once per request.
public class CustomFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// preprocessing
filterChain.doFilter(request, response);
// postprocessing
}
}
The common bug is omitting the filterChain.doFilter(...) call. The request never advances and stalls. The response stays empty and the controller is never invoked.
The chain and ordering
The container runs registered filters as a chain, in order. It passes through every filter’s preprocessing, invokes the servlet, and then runs the postprocessing in reverse order (Chain of Responsibility). Order is set with @Order or FilterRegistrationBean.setOrder; lower numbers run first.
@Order(1)
public class FirstFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
filterChain.doFilter(request, response);
}
}
There are two ways to register a filter: automatically with @Component, or explicitly with FilterRegistrationBean. Use the latter when you need to apply it only to certain URL patterns or nail down the order.
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<LoggingFilter> loggingFilter() {
FilterRegistrationBean<LoggingFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new LoggingFilter());
bean.addUrlPatterns("/*");
bean.setOrder(1);
return bean;
}
}
Why CORS and security live in filters
A filter’s position dictates its use. Two examples.
CORS has to handle the preflight OPTIONS request before it reaches a controller. OPTIONS usually has no mapped controller, so intercepting it inside the DispatcherServlet is already too late. A filter sets the response headers and ends the request immediately.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
response.setHeader("Access-Control-Allow-Origin", "*");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return; // stop here, don't proceed
}
filterChain.doFilter(request, response);
}
}
Security token validation lives in a filter for the same reason: authentication should finish as early as possible, before a controller is selected. That’s why Spring Security implements its authentication logic as a filter chain rather than as interceptors. Below is a filter that validates a JWT and places the authentication into the SecurityContext.
public class JwtAuthenticationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = request.getHeader("Authorization");
if (token != null && token.startsWith("Bearer ")) {
token = token.substring(7);
if (jwtUtil.validateToken(token)) {
String username = jwtUtil.getUsernameFromToken(token);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}
Filter vs interceptor
Place the two in the request flow and the difference is clear.
Client request
↓
Filter (preprocessing) ← servlet container level
↓
DispatcherServlet
↓
Interceptor (preHandle) ← Spring context level, the controller is already chosen
↓
Controller
↓
Interceptor (postHandle / afterCompletion)
↓
Filter (postprocessing)
↓
Client response
A filter sits outside the DispatcherServlet, so it touches every request and manipulates the raw ServletRequest/Response, but it doesn’t know which controller will run and isn’t covered by @ControllerAdvice exception handling. An interceptor sits inside, so it knows which handler will run and can use Spring features. If the work needs to know nothing about Spring and catch every request first, use a filter.
Things to watch when implementing
- Order changes behavior. Manage it explicitly with
@Order/setOrder. - A missing
doFiltercall stalls the request. After preprocessing, pass it on (the CORS preflight that ends deliberately is the exception). @ControllerAdvicecan’t catch filter exceptions. A filter is outside Spring MVC, so handle exceptions with try-catch inside the filter.- Every request goes through it, so keep it light. Narrow the scope with URL patterns or conditions so it runs only where needed.