Spring Boot: Request Interceptors with HandlerInterceptor

HandlerInterceptor is a Spring MVC mechanism that lets you intercept HTTP requests before they reach a controller, after the controller returns, and after the complete response is committed. Interceptors are ideal for cross-cutting concerns like logging, authentication, and rate limiting that need access to the Spring MVC context.

Contents

What Are Interceptors

In Spring MVC, a HandlerInterceptor allows you to execute logic at three points in the request lifecycle:

  • preHandle: runs before the controller method is invoked. Can short-circuit the request by returning false.
  • postHandle: runs after the controller returns but before the view is rendered. Gives access to the ModelAndView.
  • afterCompletion: runs after the entire request is finished (response committed). Always called if preHandle returned true, even when exceptions occur.

Interceptors operate within the Spring MVC layer, meaning they run after the DispatcherServlet has resolved the handler. This gives them access to the controller method, annotations, and Spring context, something Servlet Filters cannot do.

Interceptors are only invoked for requests handled by the DispatcherServlet. Static resources served directly by the embedded server (Tomcat, Jetty) bypass interceptors unless the resource handler is configured through Spring MVC.

Common use cases for interceptors include:

  • Request and response logging with timing metrics
  • Authentication and authorization checks before controller execution
  • Rate limiting per client or IP address
  • Setting request attributes or MDC context for downstream processing
  • Modifying the ModelAndView before view rendering

Creating an Interceptor

To create an interceptor, implement the HandlerInterceptor interface. All three methods have default implementations, so you only need to override the ones you need.

Java
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;

@Component
public class SampleInterceptor implements HandlerInterceptor {

    private static final Logger log = LoggerFactory.getLogger(SampleInterceptor.class);

    /**
     * Called before the handler method executes.
     * Return true to continue, false to abort the request.
     */
    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws Exception {
        log.info("preHandle: {} {}", request.getMethod(), request.getRequestURI());

        // Access the controller method if the handler is a HandlerMethod
        if (handler instanceof HandlerMethod hm) {
            log.debug("Controller: {}.{}()",
                hm.getBeanType().getSimpleName(),
                hm.getMethod().getName());
        }

        return true; // continue to controller
    }

    /**
     * Called after the handler returns, before view rendering.
     * Not called if preHandle returned false or an exception was thrown.
     */
    @Override
    public void postHandle(HttpServletRequest request,
                            HttpServletResponse response,
                            Object handler,
                            ModelAndView modelAndView) throws Exception {
        log.info("postHandle: {} {}", request.getMethod(), request.getRequestURI());
    }

    /**
     * Called after the complete request finishes.
     * Always called if preHandle returned true, even on exceptions.
     */
    @Override
    public void afterCompletion(HttpServletRequest request,
                                 HttpServletResponse response,
                                 Object handler,
                                 Exception ex) throws Exception {
        log.info("afterCompletion: {} {} — status {}",
            request.getMethod(), request.getRequestURI(), response.getStatus());

        if (ex != null) {
            log.error("Request resulted in exception: {}", ex.getMessage());
        }
    }
}
The postHandle method is not called when the controller throws an exception or when preHandle returns false. Use afterCompletion for cleanup logic that must always run.

Registering Interceptors

Interceptors are registered through WebMvcConfigurer by overriding the addInterceptors method. You can control which URL patterns the interceptor applies to and which patterns it should skip.

Java
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    private final SampleInterceptor sampleInterceptor;

    public WebMvcConfig(SampleInterceptor sampleInterceptor) {
        this.sampleInterceptor = sampleInterceptor;
    }

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(sampleInterceptor)
            .addPathPatterns("/api/**")              // apply to all /api/ routes
            .excludePathPatterns("/api/health")      // skip health endpoint
            .excludePathPatterns("/api/public/**");  // skip public endpoints
    }
}

Key points about path pattern configuration:

  • addPathPatterns: specifies which URL patterns the interceptor should match. Uses Ant-style patterns: /** matches all paths, /api/* matches one level, /api/** matches all levels.
  • excludePathPatterns: specifies URL patterns to skip. Exclude patterns take priority over include patterns.
  • If no path patterns are specified, the interceptor applies to all requests (/**).
Interceptors injected as Spring beans via constructor injection have full access to other beans, properties, and configuration. Avoid creating interceptors with new inside the configuration class if they depend on other Spring-managed components.

Request Logging Interceptor

A practical logging interceptor that records the HTTP method, URI, response status, and execution time for every request.

Java
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class RequestLoggingInterceptor implements HandlerInterceptor {

    private static final Logger log = LoggerFactory.getLogger(RequestLoggingInterceptor.class);
    private static final String START_TIME_ATTR = "requestStartTime";

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws Exception {
        request.setAttribute(START_TIME_ATTR, System.currentTimeMillis());
        log.info("--> {} {} [client={}]",
            request.getMethod(),
            request.getRequestURI(),
            request.getRemoteAddr());
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request,
                                 HttpServletResponse response,
                                 Object handler,
                                 Exception ex) throws Exception {
        long startTime = (Long) request.getAttribute(START_TIME_ATTR);
        long duration = System.currentTimeMillis() - startTime;

        log.info("<-- {} {} | status={} | duration={}ms",
            request.getMethod(),
            request.getRequestURI(),
            response.getStatus(),
            duration);

        if (ex != null) {
            log.error("Request failed with exception: {}", ex.getMessage(), ex);
        }
    }
}

Register the logging interceptor for all API routes:

Java
@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(requestLoggingInterceptor)
        .addPathPatterns("/**")
        .excludePathPatterns("/static/**", "/css/**", "/js/**")
        .order(0); // run first
}

Example log output:

Shell
INFO  --> GET /api/users [client=192.168.1.10]
INFO  <-- GET /api/users | status=200 | duration=45ms
INFO  --> POST /api/orders [client=192.168.1.10]
INFO  <-- POST /api/orders | status=201 | duration=123ms

Authentication Interceptor

An interceptor that validates a Bearer token in the Authorization header before the controller executes. Requests without a valid token are rejected with a 401 response.

Java
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

@Component
public class AuthInterceptor implements HandlerInterceptor {

    private final TokenService tokenService;

    public AuthInterceptor(TokenService tokenService) {
        this.tokenService = tokenService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws Exception {
        // Allow preflight CORS requests to pass through
        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            return true;
        }

        String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);

        if (authHeader == null || !authHeader.startsWith("Bearer ")) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setContentType("application/json");
            response.getWriter().write("{\"error\":\"Missing or invalid Authorization header\"}");
            return false;
        }

        String token = authHeader.substring(7);

        if (!tokenService.validateToken(token)) {
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setContentType("application/json");
            response.getWriter().write("{\"error\":\"Invalid or expired token\"}");
            return false;
        }

        // Store the authenticated user info for downstream use
        String userId = tokenService.extractUserId(token);
        request.setAttribute("authenticatedUserId", userId);

        return true;
    }
}

Register the auth interceptor on protected routes, excluding public endpoints:

Java
@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(authInterceptor)
        .addPathPatterns("/api/**")
        .excludePathPatterns(
            "/api/auth/login",
            "/api/auth/register",
            "/api/public/**",
            "/api/health"
        )
        .order(1);
}
For production applications, consider using Spring Security instead of a custom auth interceptor. Custom interceptors are useful for lightweight token checks or when Spring Security is not on the classpath.

Rate Limiting Interceptor

A simple in-memory rate limiter that restricts each client IP to a configurable number of requests per time window. Exceeding the limit returns HTTP 429 (Too Many Requests).

Java
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicInteger;

@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private static final int MAX_REQUESTS_PER_MINUTE = 60;
    private static final long WINDOW_SIZE_MS = 60_000L;

    private final ConcurrentMap<String, ClientRequestInfo> clientRequests =
        new ConcurrentHashMap<>();

    @Override
    public boolean preHandle(HttpServletRequest request,
                              HttpServletResponse response,
                              Object handler) throws Exception {
        String clientIp = request.getRemoteAddr();

        ClientRequestInfo info = clientRequests.computeIfAbsent(
            clientIp, k -> new ClientRequestInfo());

        // Reset the counter if the time window has elapsed
        long now = System.currentTimeMillis();
        if (now - info.windowStart > WINDOW_SIZE_MS) {
            info.counter.set(0);
            info.windowStart = now;
        }

        int currentCount = info.counter.incrementAndGet();

        // Add rate limit headers to every response
        response.setHeader("X-RateLimit-Limit",
            String.valueOf(MAX_REQUESTS_PER_MINUTE));
        response.setHeader("X-RateLimit-Remaining",
            String.valueOf(Math.max(0, MAX_REQUESTS_PER_MINUTE - currentCount)));

        if (currentCount > MAX_REQUESTS_PER_MINUTE) {
            response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
            response.setContentType("application/json");
            response.getWriter().write(
                "{\"error\":\"Rate limit exceeded. Try again later.\"}");
            return false;
        }

        return true;
    }

    private static class ClientRequestInfo {
        volatile long windowStart = System.currentTimeMillis();
        final AtomicInteger counter = new AtomicInteger(0);
    }
}
This in-memory rate limiter works for single-instance deployments. For distributed environments, use a shared store like Redis with a library such as Bucket4j or Resilience4j RateLimiter.

Interceptor Execution Order

When multiple interceptors are registered, they execute in a defined sequence. The order value controls priority: lower values run first in preHandle and last in afterCompletion.

Java
@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(requestLoggingInterceptor)
        .addPathPatterns("/**")
        .order(0);  // runs first

    registry.addInterceptor(authInterceptor)
        .addPathPatterns("/api/**")
        .excludePathPatterns("/api/auth/**", "/api/public/**")
        .order(1);  // runs second

    registry.addInterceptor(rateLimitInterceptor)
        .addPathPatterns("/api/**")
        .order(2);  // runs third
}

For three interceptors A (order 0), B (order 1), and C (order 2), the execution flow is:

Text
A.preHandle()  ->  B.preHandle()  ->  C.preHandle()
                                          |
                                    Controller Method
                                          |
C.postHandle() <-  B.postHandle() <-  A.postHandle()  (reverse never happens — see note)
                                          |
C.afterCompletion() -> B.afterCompletion() -> A.afterCompletion()

Key rules about execution order:

  • preHandle methods run in order (lowest to highest). If any interceptor returns false, subsequent interceptors and the controller are skipped.
  • postHandle methods run in reverse order (highest to lowest). Skipped entirely if any preHandle returned false or the controller threw an exception.
  • afterCompletion methods run in reverse order, but only for interceptors whose preHandle returned true. This guarantees cleanup even on failure.
If interceptor B's preHandle returns false, then only A's afterCompletion is called (because only A's preHandle returned true). The controller, postHandle, and C's methods are all skipped.

Interceptors vs Filters

Filters and interceptors can both intercept HTTP requests, but they operate at different layers and have different capabilities.

AspectServlet FilterHandlerInterceptor
SpecificationJakarta Servlet APISpring MVC
Execution pointBefore/after DispatcherServletAfter DispatcherServlet, around the handler
Access to handler/controllerNoYes (via HandlerMethod)
Access to ModelAndViewNoYes (in postHandle)
Can modify request/responseYes (wrapper classes)Limited (can set attributes, write to response)
Exception handlingMust handle in filterafterCompletion receives the exception
URL pattern matchingServlet URL patternsAnt-style Spring MVC patterns
Spring bean injectionVia @Component or FilterRegistrationBeanFull Spring bean support
Works with non-MVC requestsYes (all servlet requests)No (only DispatcherServlet requests)

Use a Filter when you need to:

  • Modify the raw request or response (e.g., wrapping the input stream, compressing the response)
  • Intercept all servlet requests including static resources and non-Spring endpoints
  • Work at the lowest level before Spring MVC processing (e.g., CORS headers, encoding)

Use an Interceptor when you need to:

  • Access the controller method or its annotations (e.g., checking a custom @RateLimit annotation)
  • Run logic only for specific controller routes using Ant-style path patterns
  • Modify the ModelAndView before view rendering
  • Use afterCompletion for guaranteed cleanup with exception visibility