Spring Boot Built-in Filters Explained: Usage, Customization, and Performance Tips

This article provides a comprehensive guide to Spring Boot's built-in servlet filters, shows how to implement custom filters using the Filter interface, OncePerRequestFilter, and FilterRegistrationBean, explains filter ordering and configuration, and presents practical scenarios with performance‑optimisation best practices.

Cloud Architecture
Cloud Architecture
Cloud Architecture
Spring Boot Built-in Filters Explained: Usage, Customization, and Performance Tips

Introduction

Filters are the core mechanism for handling cross‑cutting concerns such as logging, security, CORS, compression, and request tracing in Java web applications. Spring Boot automatically registers a set of built‑in filters and also supports flexible custom filter implementations.

Servlet Filter Basics

A servlet filter follows a simple lifecycle: init() (executed once), doFilter() (executed for each request), and destroy() (executed on container shutdown).

public class SimpleFilter implements Filter {
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        String param = filterConfig.getInitParameter("configParam");
        System.out.println("Filter init, param: " + param);
    }
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        System.out.println("[" + System.currentTimeMillis() + "] " +
                httpRequest.getMethod() + " " + httpRequest.getRequestURI());
        chain.doFilter(request, response);
        System.out.println("Response completed: " + httpRequest.getRequestURI());
    }
    @Override
    public void destroy() {
        System.out.println("Filter destroyed");
    }
}

Spring Boot Built-in Filters

Spring Boot auto‑configures the following filters (ordered by execution priority):

CharacterEncodingFilter – sets request/response character encoding.

FormContentFilter – parses form data for PUT, PATCH, DELETE.

HiddenHttpMethodFilter – converts a hidden _method field to the actual HTTP method.

CORS Filter – handles cross‑origin requests.

Spring Security filter chain – provides authentication and authorization.

WebMvcMetricsFilter – collects request metrics via Micrometer.

Example of the CharacterEncodingFilter configuration:

@ConfigurationProperties(prefix = "spring.http.encoding")
public class HttpEncodingAutoConfiguration {
    @Bean
    @Order(Ordered.HIGHEST_PRECEDENCE)
    public CharacterEncodingFilter characterEncodingFilter() {
        CharacterEncodingFilter filter = new OrderedCharacterEncodingFilter();
        filter.setEncoding(this.properties.getCharset().name());
        filter.setForceRequest(this.properties.isForceRequest());
        filter.setForceResponse(this.properties.isForceResponse());
        return filter;
    }
}

Corresponding application.yml snippet:

spring:
  http:
    encoding:
      enabled: true
      charset: UTF-8
      force: true
      force-request: true
      force-response: true

Custom Filter Implementations

1. Implementing the Filter interface

@Component
@WebFilter(urlPatterns = "/*", filterName = "loggingFilter")
public class RequestLoggingFilter implements Filter {
    private static final Logger logger = LoggerFactory.getLogger(RequestLoggingFilter.class);
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;
        String requestId = UUID.randomUUID().toString().replace("-", "");
        long start = System.currentTimeMillis();
        try {
            chain.doFilter(request, response);
        } finally {
            long duration = System.currentTimeMillis() - start;
            logger.info("Request ID: {} | Method: {} | URI: {} | Status: {} | Duration: {}ms",
                    requestId, httpRequest.getMethod(), httpRequest.getRequestURI(),
                    httpResponse.getStatus(), duration);
        }
    }
}

2. Extending OncePerRequestFilter

@Component
@Order(1)
public class ApiAuditFilter extends OncePerRequestFilter {
    private final ObjectMapper objectMapper = new ObjectMapper();
    private final AuditLogService auditLogService;
    private static final Set<String> AUDIT_PATHS = Set.of("/api/orders/","/api/payments/","/api/users/","/api/admin/");
    private static final Set<String> SENSITIVE_FIELDS = Set.of("password","token","secret","creditCard","ssn");
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String requestId = UUID.randomUUID().toString().replace("-", "");
        long start = System.currentTimeMillis();
        ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
        ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);
        wrappedResponse.setHeader("X-Trace-ID", requestId);
        try {
            chain.doFilter(wrappedRequest, wrappedResponse);
            if (shouldAudit(request.getRequestURI())) {
                logAudit(requestId, wrappedRequest, wrappedResponse, System.currentTimeMillis() - start);
            }
        } finally {
            wrappedResponse.copyBodyToResponse();
        }
    }
    private boolean shouldAudit(String uri) {
        return AUDIT_PATHS.stream().anyMatch(uri::startsWith);
    }
    // logAudit method (omitted for brevity) sanitises request/response bodies and saves them asynchronously.
}

3. Using FilterRegistrationBean

@Configuration
public class FilterConfig {
    @Bean
    public FilterRegistrationBean<RequestLoggingFilter> loggingFilter() {
        FilterRegistrationBean<RequestLoggingFilter> registration = new FilterRegistrationBean<>();
        registration.setFilter(new RequestLoggingFilter());
        registration.addUrlPatterns("/*");
        registration.setName("loggingFilter");
        registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);
        registration.addInitParameter("includeRequestBody", "true");
        registration.addInitParameter("includeResponseBody", "true");
        registration.addInitParameter("maxPayloadLength", "10000");
        return registration;
    }
}

Filter Chain Configuration and Ordering

Filters are executed in ascending order of their order value (smaller value = higher priority). The diagram below illustrates the request flow:

Request → Filter1 (Order1) → Filter2 (Order10) → Filter3 (Order20) → Controller
          ↓                     ↓                     ↓
Response ← Filter3 ← Filter2 ← Filter1

Three common ways to set order:

Annotate the filter bean with @Order.

Implement Ordered and return a custom order value.

Configure the order via FilterRegistrationBean.setOrder().

Practical Scenarios

Scenario 1 – API Gateway Request Logging & Auditing

The ApiAuditFilter records detailed request/response information, sanitises sensitive fields, and stores the audit log asynchronously.

Scenario 2 – Rate‑Limiting Filter

@Component
@Order(2)
public class RateLimitFilter extends OncePerRequestFilter {
    private final RateLimitProperties properties;
    private final LoadingCache<String, RateLimiter> ipRateLimiter = CacheBuilder.newBuilder()
            .expireAfterAccess(1, TimeUnit.MINUTES)
            .build(new CacheLoader<String, RateLimiter>() {
                @Override public RateLimiter load(String key) { return RateLimiter.create(properties.getIpLimitPerSecond()); }
            });
    private final LoadingCache<String, RateLimiter> userRateLimiter = CacheBuilder.newBuilder()
            .expireAfterAccess(1, TimeUnit.MINUTES)
            .build(new CacheLoader<String, RateLimiter>() {
                @Override public RateLimiter load(String key) { return RateLimiter.create(properties.getUserLimitPerSecond()); }
            });
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String uri = request.getRequestURI();
        if (isExcluded(uri)) { chain.doFilter(request, response); return; }
        String clientIp = getClientIp(request);
        if (!tryAcquire(ipRateLimiter, clientIp)) { sendRateLimitResponse(response, "IP request rate too high", clientIp); return; }
        String userId = getCurrentUserId(request);
        if (userId != null && !tryAcquire(userRateLimiter, userId)) {
            sendRateLimitResponse(response, "User request rate too high", userId); return; }
        chain.doFilter(request, response);
        if (userId != null) {
            RateLimiter limiter = userRateLimiter.getUnchecked(userId);
            response.setHeader("X-RateLimit-Remaining", String.valueOf((int) limiter.getAvailablePermits()));
        }
    }
    // Helper methods (isExcluded, tryAcquire, getClientIp, getCurrentUserId, sendRateLimitResponse) omitted for brevity.
}

Scenario 3 – Response Compression Filter

@Component
@Order(Ordered.LOWEST_PRECEDENCE - 10)
public class CompressionFilter extends OncePerRequestFilter {
    private static final int COMPRESSION_THRESHOLD = 1024; // bytes
    private static final List<String> COMPRESSIBLE_MIME_TYPES = List.of(
            "application/json","application/xml","text/html","text/plain","text/css","application/javascript");
    private static final List<String> EXCLUDED_MIME_TYPES = List.of(
            "image/png","image/jpeg","image/gif","application/pdf","application/zip");
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        String acceptEncoding = request.getHeader("Accept-Encoding");
        if (acceptEncoding == null || !acceptEncoding.contains("gzip")) { chain.doFilter(request, response); return; }
        CompressionResponseWrapper wrapped = new CompressionResponseWrapper(response);
        try { chain.doFilter(request, wrapped); }
        finally { wrapped.compressIfNeeded(); }
    }
    // Inner class CompressionResponseWrapper implements the actual GZIP logic (omitted for brevity).
}

Scenario 4 – Multi‑Tenant Context Filter

@Component
@Order(3)
public class TenantContextFilter extends OncePerRequestFilter {
    private static final String TENANT_HEADER = "X-Tenant-ID";
    private static final String TENANT_SUBDOMAIN_PATTERN = "^([a-zA-Z0-9-]+)\\.(?:example\\.com|localhost)$";
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        try {
            String tenantId = request.getHeader(TENANT_HEADER);
            if (tenantId == null || tenantId.isEmpty()) tenantId = extractFromSubdomain(request);
            if (tenantId == null || tenantId.isEmpty()) tenantId = extractFromToken(request);
            if (tenantId != null && !tenantId.isEmpty()) {
                TenantContext.setTenantId(tenantId);
                MDC.put("tenantId", tenantId);
            }
            chain.doFilter(request, response);
        } finally {
            TenantContext.clear();
            MDC.clear();
        }
    }
    // extractFromSubdomain, extractFromToken (JWT parsing) omitted for brevity.
}

Performance Optimisation & Best Practices

1. Avoid Time‑Consuming Operations Inside Filters

Never perform blocking database queries or synchronous HTTP calls directly in doFilterInternal. Use caches or asynchronous processing instead.

2. Use Request/Response Wrappers Judiciously

Wrap only when the request size is reasonable and the endpoint requires body inspection. Large file uploads should bypass caching to avoid memory pressure.

3. Asynchronous Filters

For logging or tracing that does not need to block the request thread, extend AsyncFilterSupport and perform work after the request has been dispatched.

4. Filter Performance Monitoring

@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 100)
public class FilterPerformanceMonitor extends OncePerRequestFilter {
    private final MeterRegistry meterRegistry;
    public FilterPerformanceMonitor(MeterRegistry meterRegistry) { this.meterRegistry = meterRegistry; }
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {
        long start = System.nanoTime();
        try { chain.doFilter(request, response); }
        finally {
            long duration = System.nanoTime() - start;
            Timer timer = meterRegistry.timer("filter.execution.time", "filter", getClass().getSimpleName(), "uri", request.getRequestURI());
            timer.record(duration, java.util.concurrent.TimeUnit.NANOSECONDS);
            if (duration > 100_000_000) { // >100ms
                logger.warn("Filter {} took {}ms for URI {}", getClass().getSimpleName(), duration/1_000_000.0, request.getRequestURI());
            }
        }
    }
}

5. Configuration Best Practices

Use @ConditionalOnProperty to enable/disable filters via configuration.

Separate production and development filter beans with @Profile annotations.

Prefer constants for order values (e.g., FilterConstants.CHARACTER_ENCODING_FILTER_ORDER) to avoid magic numbers.

Common Issues & Troubleshooting

Filter Not Effective

Possible causes:

Filter bean not registered (missing @Component or FilterRegistrationBean).

Servlet component scanning not enabled for @WebFilter (add @ServletComponentScan to the application class).

Incorrect URL pattern or order.

Solution: Verify bean registration, URL patterns, and order using a debugger bean that prints all registered filters and their order.

Response Body Empty

If ContentCachingResponseWrapper is used, always call copyBodyToResponse() in a finally block; otherwise the client receives an empty payload.

Filter Order Conflicts with Spring Security

Custom filters that need authentication data must be placed after the Spring Security filter chain, e.g., using

http.addFilterAfter(customFilter, UsernamePasswordAuthenticationFilter.class)

in the security configuration.

Conclusion

Spring Boot filters provide a powerful, extensible way to address cross‑cutting concerns. Understanding the built‑in filter chain, correctly implementing custom filters, managing ordering, and following performance best practices enable developers to build robust, observable, and secure web services.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Performance Optimizationspring-bootSecurityCustom FiltersServlet Filters
Cloud Architecture
Written by

Cloud Architecture

Focuses on cloud‑native and distributed architecture engineering, sharing practical solutions and lessons learned. Covers microservice governance, Kubernetes, observability, and stability engineering to help your systems run stable, fast, and cost‑effectively.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.