Filters, Interceptors and AOP: Order and Practice

bee2026-10-0861 min read0 views
Three mechanisms can all "intercept a request" — which one? From Tomcat's Filter to MVC's HandlerInterceptor to method-level AOP, one sequence diagram and a real login interceptor settle it.
1 / 146
Section
0. The 30-second version: four checkpoints at the airport
2 / 146

Set aside the names Filter, Interceptor and AOP for a moment. All you need to remember is this: an HTTP request is a passenger walking from the terminal entrance to the gate, and it passes several completely different checkpoints along the way — each able to see a wildly different amount of information.

3 / 146
Diagram
Figure 2 · Three checkpoints of one request
Figure 2 · Three checkpoints of one request
4 / 146
Analogy

the check-in desk is a Filter. It sits in the Servlet container (Tomcat, the server that actually accepts network connections) and inspects only your ID card and your luggage — it knows "a person", but has no idea which flight you are on or which gate. The security checkpoint is an Interceptor, owned by Spring MVC, and it holds the flight manifest: it can see exactly which method of which class you are about to call (an object called HandlerMethod), so it can say "you are not on this flight". The gate re-check is AOP (aspect-oriented programming — the technique of weaving "the chores every method must do" uniformly around a method call), and it watches only the act of boarding; it has no say in how you walked through the terminal.

5 / 146
Analogy

what is afterCompletion (the callback that always fires when the request wraps up)? It is settling the bill at departure. Every earlier stage may go wrong — a delay, a rebooking, a refused boarding — but the cashier does not care how the play went; at departure they settle up and clear the table. That is why "measure elapsed time" and "clear the ThreadLocal (a private locker tied to the thread)" always belong in afterCompletion, never in postHandle, which only fires for passengers who made it.

6 / 146

After this article you should be able to answer three questions:

7 / 146
  • A Filter, an Interceptor and an aspect all look like they can "intercept" a request. Which one can see the method name, and why does that single fact decide everything?
  • The controller throws an exception. Which hooks still run, which are skipped, and where does the exception actually go?
  • I need a login check that returns a uniform JSON error body and can whitelist /login. Which layer, and why is the obvious alternative worse?
8 / 146
Section
1. Three ways to "intercept" — what actually differs
9 / 146

"Should the login check live in a Filter or an Interceptor?" "Should request timing use an interceptor or AOP?" Almost every Spring newcomer hits the same fork: all three mechanisms seem able to "intercept" a request before the business code runs, so you pick one at random — until the day you discover "the interceptor can't see the method name" or "the aspect can't catch static resources" and go back to study.

10 / 146

Define the roles first; the choice then follows naturally:

11 / 146
Table
MechanismSpec / containerGranularityWhat it can seeTypical use
FilterServlet spec (Tomcat)One HTTP request / responseServletRequest / ServletResponse; it does not know which controller method will runCORS, charset, request logging, gzip, XSS filtering
InterceptorSpring MVCHandler level (around the controller)The HandlerMethod (which method of which bean), ModelAndViewLogin checks, authorization, auditing, i18n
AOPSpring container (method level)Any method of any Spring beanSignature, arguments, return value, thrown exceptionTransactions, caching, timing, idempotency, rate limiting
12 / 146
Note

the real watershed is whether it knows the business method. A Filter lives in the Servlet world and only sees HttpServletRequest; it has no idea which controller will run. An Interceptor is owned by Spring MVC and can access the HandlerMethod. AOP goes even deeper, wrapping an individual bean method. Want the logic "closer to business"? Use Interceptor / AOP. Want it "closer to the container, applying to every request"? That is what Filter is for.

13 / 146
Section
2. The full sequence: who runs first
14 / 146
Diagram
Figure 1 · The interception layers
Figure 1 · The interception layers
15 / 146

Flatten one normal request and the order is:

16 / 146
Code
Codetext
Request arrives  └─ Filter chain (code before chain.doFilter, forward order)       └─ DispatcherServlet.doDispatch            └─ Interceptor.preHandle (forward order)                 └─ Controller method ── (wrapped inside by AOP @Around: before → target → after)                      └─ Interceptor.postHandle (reverse order, only on success)                           └─ View rendering / response writing  └─ Interceptor.afterCompletion (reverse order, finally semantics, always runs)       └─ Filter chain (code after chain.doFilter, reverse order)            └─ Response back to the client
Notes
  • Front hooks run forward, back hooks run in reverse — like nested dolls, unwinding in the opposite order
  • postHandle runs only when the controller returns successfully; it holds the ModelAndView, so it can adjust the view — rarely needed in REST
  • afterCompletion has finally semantics: as long as its preHandle once returned true, it runs whether the request succeeded or failed — ideal for clearing ThreadLocals and closing resources
17 / 146
Animation
Animation · Three interceptor hooks
Animation · Three interceptor hooks
18 / 146

What if the controller throws? The exception path is: Filter before-code → preHandle → controller throws → postHandle is skipped → afterCompletion (reverse order, exception passed as the argument) → the exception is handed to HandlerExceptionResolver → response is written → Filter after-code. Remember one line: postHandle is for success, afterCompletion is for cleanup — one may be skipped, the other always runs.

19 / 146

That line can only be read once. The diagram below unfolds it into nine stations you can click through — when learning an order, what matters is not memorising it but knowing who each station is waiting for:

20 / 146
Diagram
FlowThe nine stations of one request: which run forward, which reverse1 / 9
Click ①→⑨, then backwards: forward happens at ①②③, reverse at ⑥⑦⑧⑨ — that is the whole difference
→
→
→
→
→
→
→
→
① Filter, before-code (forward)
The container layer. Tomcat walks the filters in registration order, running everything before chain.doFilter. It is earlier than Spring MVC: DispatcherServlet has not even taken over yet.
All clearRather than memorising the order, remember one fact: three forward stations, four reverse ones, and ⑥ is the only station that can be skipped.
21 / 146
Section
3. Filter in practice: CORS and access logging
22 / 146

Filters suit chores that are business-agnostic and apply to every request. Two classics: CORS and access logs.

23 / 146

A CORS filter must run first, or preflight (OPTIONS) requests get blocked by later logic:

24 / 146
Code
Codejava
@Component@Order(Ordered.HIGHEST_PRECEDENCE)                      // CORS must run firstpublic class CorsFilter extends OncePerRequestFilter {  // guarantees a single run per request    @Override    protected void doFilterInternal(HttpServletRequest request,                                    HttpServletResponse response,                                    FilterChain chain) throws ServletException, IOException {        response.setHeader("Access-Control-Allow-Origin", request.getHeader("Origin"));        response.setHeader("Access-Control-Allow-Credentials", "true");        response.setHeader("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS");        response.setHeader("Access-Control-Allow-Headers", "*");        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {            response.setStatus(HttpServletResponse.SC_OK);   // finish preflight, skip business            return;        }        chain.doFilter(request, response);                    // pass to the next filter    }}
Notes
  • Extend OncePerRequestFilter rather than implement Filter directly, so forward/include do not run it twice
  • @Order(HIGHEST_PRECEDENCE) puts it before every other filter — CORS headers must be written first
  • chain.doFilter(request, response) is the dividing line: before it is "before", after it is "after"
  • OPTIONS preflight has no body; returning 200 directly avoids the business code
25 / 146

A request-timing log demonstrates the "after" code:

26 / 146
Code
Codejava
@Component@Order(1)public class AccessLogFilter extends OncePerRequestFilter {    private static final Logger log = LoggerFactory.getLogger(AccessLogFilter.class);    @Override    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)            throws ServletException, IOException {        long start = System.currentTimeMillis();        try {            chain.doFilter(req, res);                        // "before": the clock starts        } finally {            long cost = System.currentTimeMillis() - start;  // "after": written in finally            log.info("{} {} status={} cost={}ms", req.getMethod(),                     req.getRequestURI(), res.getStatus(), cost);        }    }}
Notes
  • "Start" goes before chain.doFilter, "end" after it — the Filter's before/after model in one picture
  • Putting the log in finally records timing and status even when business code throws
  • res.getStatus() is already final here, because the after-code runs before the response is committed
27 / 146
Section
3.1 Two ways to register: FilterRegistrationBean vs @WebFilter
28 / 146

A @Component Filter is auto-registered by Spring Boot, but its order is hard to control. For precise ordering use FilterRegistrationBean:

29 / 146
java
@Configurationpublic class FilterConfig {    @Bean    public FilterRegistrationBean<AccessLogFilter> accessLog() {        FilterRegistrationBean<AccessLogFilter> reg = new FilterRegistrationBean<>(new AccessLogFilter());        reg.addUrlPatterns("/*");        // intercept every path        reg.setOrder(1);                 // smaller number runs earlier        reg.setName("accessLogFilter");        return reg;    }}
30 / 146

The annotation route exists too, but the app must scan it:

31 / 146
java
@WebFilter(urlPatterns = "/*", filterName = "accessLogFilter")public class AccessLogFilter extends OncePerRequestFilter { /* ... */ }// Add this to the main class, or @WebFilter silently does nothing@ServletComponentScan@SpringBootApplicationpublic class DemoApplication { }
32 / 146
Table
RegistrationOrder controlDependency injectionBest for
FilterRegistrationBeansetOrder, preciseNaturalWhen order and injected beans matter (recommended)
@WebFilter + @ServletComponentScan@Order, easy to muddleVia container callbacksSimple cases, native Servlet habits
Plain @Component@OrderSupportedGlobal default, but ordering is opaque
33 / 146
Tip

the classic @WebFilter trap is forgetting @ServletComponentScan. The annotation is there, the class is there, but the filter never runs — because it is not picked up by Spring component scanning, and Servlet component scanning must be enabled explicitly.

34 / 146
Section
4. HandlerInterceptor in practice: a login check
35 / 146

The login check is the Interceptor's home turf: it knows which method will run and can be whitelisted precisely with excludePathPatterns.

36 / 146
Code
Codejava
@Componentpublic class LoginInterceptor implements HandlerInterceptor {    private final TokenService tokenService;         // injected; note it is a singleton    public LoginInterceptor(TokenService tokenService) {        this.tokenService = tokenService;    }    @Override    public boolean preHandle(HttpServletRequest request,                             HttpServletResponse response,                             Object handler) throws Exception {        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {            return true;                             // let CORS preflight through        }        String token = request.getHeader("Authorization");        LoginUser user = tokenService.parse(token);  // null when invalid / expired        if (user == null) {            throw new UnauthorizedException("Please sign in");   // throw, let the global handler respond        }        UserContext.set(user);                       // stash in a ThreadLocal for later business code        return true;                                 // true continues the chain    }    @Override    public void postHandle(HttpServletRequest request, HttpServletResponse response,                           Object handler, ModelAndView mv) {        // Runs only when the controller returns successfully; usually a no-op in REST projects    }    @Override    public void afterCompletion(HttpServletRequest request, HttpServletResponse response,                                Object handler, Exception ex) {        UserContext.clear();                         // ALWAYS runs: clear the ThreadLocal to avoid leakage    }}
Notes
  • preHandle returning true passes, false aborts — the only semantics to remember
  • Throw UnauthorizedException(...) instead of returning false, so the global @RestControllerAdvice returns a consistent error body (see the table in Section 5)
  • The Exception ex argument of afterCompletion: null means it finished normally, non-null means the controller threw — it is your last chance to tell whether the request succeeded
  • afterCompletion is always for cleanup: ThreadLocals, counters, request-scoped caches
37 / 146

Register the interceptor and configure include/exclude patterns:

38 / 146
Code
Codejava
@Configurationpublic class WebMvcConfig implements WebMvcConfigurer {    private final LoginInterceptor loginInterceptor;    public WebMvcConfig(LoginInterceptor loginInterceptor) {        this.loginInterceptor = loginInterceptor;    }    @Override    public void addInterceptors(InterceptorRegistry registry) {        registry.addInterceptor(loginInterceptor)                .addPathPatterns("/api/**")              // only business APIs                .excludePathPatterns(                    // whitelist; first match wins                        "/api/auth/**",                  // login, register                        "/actuator/**",                  // health checks                        "/error",                        // error page                        "/favicon.ico"                );    }}
Notes
  • addPathPatterns / excludePathPatterns use Ant-style paths: /** matches multiple levels, /* matches one
  • Registration order equals execution order (preHandle forward); the back hooks run in reverse
  • The interceptor is a singleton bean and can inject TokenService normally — but must never store request data in fields (see Section 9)
39 / 146
Section
5. preHandle returns false, or throws
40 / 146

This is the table to remember — it decides whether the client gets a proper error at all:

41 / 146
Table
DimensionpreHandle returns falsepreHandle throws
Controller invokedNoNo
Response behaviourYou must write the response yourself, or the client gets an empty / ambiguous 200Propagates to DispatcherServlet, handed to HandlerExceptionResolver
postHandle runsNoNo
afterCompletion runsNot for this interceptor (only earlier ones that already passed do)Yes for all interceptors that passed; the exception is passed in
Consistent error bodyHand-written; easily diverges from the global oneReuses @RestControllerAdvice, consistent by default
Best forWhen you already produced the response (redirect, JSON / HTML)When you want a standard error body and shared global handling
42 / 146
Trap

when preHandle returns false, that interceptor's own afterCompletion will not run. DispatcherServlet aborts during applyPreHandle, triggering afterCompletion only for interceptors that already returned true. If you set a ThreadLocal in preHandle and clear it in afterCompletion, one false return leaves residue — decide exactly where cleanup happens.

43 / 146
Key point

when you can produce a standard error, throw; returning false is only for "I already wrote the response". This echoes the DispatcherServlet conclusion in Article 22: an interceptor guards the door, and a failed guard should give a clear signal, not leave the client staring at an empty response.

44 / 146

The two paths differ by one line of source but by an order of magnitude in outcome. Step through the debugger and watch exactly what gets skipped — pay attention to step 2 (the short circuit) and step 4 (why postHandle never gets a second chance):

45 / 146
Stepper
StepperThe fork inside doDispatch: return false versus throw1 / 7
Seven steps covering both roads. Step 2 is the short circuit; step 4 answers "why is postHandle never replayed"
Code under debug
1boolean interest = mappedHandler.applyPreHandle(request, response);
2if (!interest) { return; } // short circuit: nothing below happens
3mv = ha.handle(request, response, mappedHandler.getHandler);
4mappedHandler.applyPostHandle(request, response, mv);
5} catch (Exception ex) { dispatchException = ex; }
6processDispatchResult(request, response, mappedHandler, mv, dispatchException);
7mappedHandler.triggerAfterCompletion(request, response, ex);
Variables now
interceptors2
currently runningAuthInterceptor.preHandle
passed counter0
Call stack
1DispatcherServlet.doDispatch
2HandlerExecutionChain.applyPreHandle
1applyPreHandle is a forward for-loop that calls each interceptor and keeps a counter. That counter decides who receives the completion callback at step 7 — the detail almost everyone misses.
46 / 146
Animation
Animation · The instant preHandle returns false
Animation · The instant preHandle returns false
47 / 146

Frame ⑤ of that animation is the expensive one: the interceptor that returned false never gets its own afterCompletion. Metrics, ThreadLocal cleanup and auditing written in its completion hook silently miss every request it rejects — which is precisely what the "swap the registration order" exercise in Tier 2 of Section 18 makes you observe.

48 / 146
Section
6. The boundaries of AOP: why it cannot catch Filter / Interceptor
49 / 146

Many people wonder, on first meeting AOP: "If an aspect can wrap methods, can it intercept the whole request?" The answer is no, and the reason lives in the word "proxy".

50 / 146
  • AOP works by creating a proxy for a bean that the Spring container manages; a call must go through the injected proxy for the aspect to fire
  • The Servlet container (Tomcat) instantiates and calls Filters directly, bypassing the Spring proxy entirely — no aspect can attach
  • Spring MVC calls the registered interceptor object directly; preHandle is not a proxied bean method either, so aspects miss it too
  • More fundamentally: AOP wraps a method call, so the request must already be inside the method body. The whole point of preHandle is to reject a request before the method is invoked — AOP simply cannot do that; by the time it steps in, the business method is already running
51 / 146

AOP's real home is inside methods: transactions, caching, timing, idempotency, rate limiting. It has boundaries too, worth knowing before use:

52 / 146
  • It applies only to Spring-managed beans; objects you new yourself have no proxy
  • Self-invocation inside the same class (this.otherMethod()) bypasses the proxy, so the aspect is skipped — inject the bean into itself or use AopContext.currentProxy()
  • private / final methods cannot be overridden by CGLIB, so the aspect is skipped as well
53 / 146
Note

in one line — Filter decides "can it get in", Interceptor decides "should it be passed to this handler", AOP decides "what happens before/after this method call". They relay rather than replace each other.

54 / 146
Section
7. Ordering: how each mechanism sorts itself
55 / 146

All three can be ordered, but differently; mixing them up is easy. Rather than memorising a table, play a round: the left column is the code you write, the right column is the rule that actually applies — a wrong pick explains itself immediately.

56 / 146
Match
MatchOrdering code ↔ the rule that actually appliesMatched 0/6 · Missed 0
Six hard mappings. The third one is the most-missed answer on the site: interceptors never look at a number
Pick a card on the left first
57 / 146
Key point

all three agree that "smaller comes first", but the Interceptor ignores the number and uses only registration order — the easiest discrepancy to trip over. To reorder interceptors, change the addInterceptor call sequence; @Order has no effect on them.

58 / 146
Section
8. Putting it together: a traceId across the whole chain
59 / 146

Real tracing needs "one traceId per request", carried from the outermost layer into the logs. This is where the three cooperate: the Filter creates and binds it, and the Interceptor / AOP / business just reuse it.

60 / 146
Code
Codejava
@Component@Order(Ordered.HIGHEST_PRECEDENCE)public class TraceIdFilter extends OncePerRequestFilter {    @Override    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)            throws ServletException, IOException {        String traceId = Optional.ofNullable(req.getHeader("X-Trace-Id"))                .orElseGet(() -> UUID.randomUUID().toString().replace("-", ""));        MDC.put("traceId", traceId);                 // bind to the current thread; logs pick it up        res.setHeader("X-Trace-Id", traceId);        // echo it back for cross-checking        try {            chain.doFilter(req, res);        } finally {            MDC.remove("traceId");                   // the thread pool reuses threads; always clean up        }    }}
Notes
  • Generate the traceId in the Filter: it runs first, guaranteeing a value for everything downstream
  • MDC (SLF4J's mapped diagnostic context) binds the traceId to the current thread so the logging framework prints it automatically
  • Remove it in finally: Tomcat's thread pool reuses threads, and leftover state leaks one request's traceId into the next
61 / 146

Reference %X{traceId} in the log pattern to print it automatically:

62 / 146
Code
Codexml
<!-- logback-spring.xml --><pattern>%d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId}] %logger{36} - %msg%n</pattern>
Notes
  • %X{traceId} reads the current thread's traceId from the MDC
  • Interceptors, aspects and business code can just call MDC.get("traceId") — no parameter threading
  • This is the classic "Filter generates → MDC shares → everyone reuses" combination
63 / 146

That relay — generate once, reuse everywhere, clean up at the end — deserves its own walkthrough, because the classic beginner failure here is doing the first two steps and forgetting the third. When traceIds in production logs belong to the wrong request, eight times out of ten the remove call in finally was skipped:

64 / 146
Animation
Animation · One traceId, handed along
Animation · One traceId, handed along
65 / 146
Trap

the MDC is backed by a ThreadLocal, so async threads (@Async, thread pools, CompletableFuture) cannot see the parent thread's traceId. Crossing threads requires wrapping the task manually (MDC.getCopyOfContextMap() before submitting, setContextMap before running), or async logs will miss or mix up traceIds.

66 / 146
Section
9. Two traps you will definitely hit
67 / 146
Trap

an interceptor is a singleton bean — never store request-private state in its fields. There is exactly one LoginInterceptor instance, shared across concurrent requests. If you write private LoginUser currentUser; and assign it in preHandle, request B overwrites request A's user, causing a privilege leak — a sporadic, nearly unreproducible bug. Store it in a ThreadLocal (cleared afterwards) or request.setAttribute, never in a field.

68 / 146
Trap

an interceptor only affects requests that enter DispatcherServlet; static resources often slip through. When static resources are served by the container's default Servlet, they never reach DispatcherServlet and the interceptor does not apply; when they are served by Spring MVC's ResourceHttpRequestHandler, a / interceptor catches them and blocks JS / CSS requests behind a login check. Conclusion: always exclude static resource paths (e.g. /static/, /favicon.ico) with excludePathPatterns; do not count on "it does nothing by default".

69 / 146
Section
70 / 146

The demo below turns the DispatcherServlet dispatch flow into switchable paths. Switch between /users/42, /users and /nope, and watch the order of argument resolution and controller execution — preHandle happens before both, which is exactly why an interceptor can reject a request early.

71 / 146
Kernel lab
TeaVMTrace the chain: where is the interceptoridle
Watch the order of argument resolution and execution to see when the pre-interceptor fires
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
72 / 146
Section
11. Decision: which layer owns the login check
73 / 146
Decision
Decisiona headless backend needs a uniform login check and must return a consistently structured JSON error when the user is not authenticated. Where should the check live?
74 / 146
Section
12. Lab two: the five contested scenarios, clicked one by one
75 / 146

The first ten sections were talking. This one is running. The demo below turns the five things people argue about most in this chapter into switchable parameters — work through order → pre → ex → cors → scope in that order, and each cell maps onto a real ticket.

76 / 146
Diagram
Figure 4 · What each layer can actually reach
Figure 4 · What each layer can actually reach
77 / 146

The same need, placed on the wrong layer, simply loses its powers — which is exactly what that diagram catalogues:

78 / 146
Table
You want to…Only this layer can do itWhy the others cannot
Add CORS headers, gzip or a traceId to every responseFilterAn interceptor sits inside the MVC layer and an aspect cannot reach static resources or the container's own filters at all
Reject a request by reading @LoginRequired on the methodInterceptorA Filter has no HandlerMethod, so it can only match URL strings; an aspect fires after the method has already been invoked
Time a service call, or make it idempotentAOPA Filter cannot see a service bean; an interceptor only wraps controllers, so @Service internals escape it
Answer 401 before the controller runsInterceptor (preHandle)An aspect runs around the invocation, so the method is already on the stack; a Filter can abort too, but has no method-level whitelist
Clear a ThreadLocal no matter how the request endedInterceptor (afterCompletion)AOP advice runs only if you catch the exception yourself; a Filter's finally works but cannot reach per-handler state
79 / 146
Kernel lab
TeaVMWho runs first: registration order versus @Orderidle
Start with order, then pre, to see when preHandle fires and when a false return aborts the chain
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
80 / 146

What each of the five parameters is supposed to show you:

81 / 146
  • order: the complete line — container filters (forward) → DispatcherServlet → preHandle (forward) → argument resolution and validation → controller → postHandle (reverse) → exception resolution → afterCompletion (reverse) → filter after-code (reverse). Note that afterCompletion runs earlier than the body is actually flushed out
  • pre: the moment preHandle returns false, applyPreHandle aborts — later interceptors, argument resolution, the controller and postHandle are all skipped, and only interceptors that already passed receive afterCompletion. That is the number one reason counters silently under-report
  • ex: when the controller throws, applyPostHandle is skipped wholesale — even if @RestControllerAdvice later turns the exception into a tidy 400, postHandle is never replayed, while afterCompletion always runs and carries the original exception
  • cors: which of the three CORS placements (Filter / addCorsMappings / @CrossOrigin) survives. A preflight carries no credentials, so authorisation ordered ahead of CORS judges a legitimate OPTIONS request as 401
  • scope: the hard boundary of what each checkpoint can see — the diagram above, in motion
82 / 146
Section
13. Lab three: what Spring Security's filter chain actually looks like
83 / 146

In the traceId combination of Section 8 you notice a clash as soon as the project pulls in Spring Security: "your own interceptor" and "Security's filters" are competing for the same job. Look at the structure of its chain first — the Security chain is itself a dozen filters, bridged by a DelegatingFilterProxy (a container filter that delegates to Spring beans) into a FilterChainProxy that picks the concrete chain.

84 / 146
Analogy

this is two parallel security lanes at the airport. Your LoginInterceptor is the extra re-check the airline adds at the gate; the Security chain is the airport's own screening, and every passenger walks through it. permitAll only means "the last officer nods at you" — the metal detector still happens. So marking a public endpoint permitAll saves no time, it only removes "must you be signed in".

85 / 146
Kernel lab
TeaVMInside Spring Security's filter chainidle
Walk chain, then anon and login, to see where authentication happens relative to your own filters
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
86 / 146

Three conclusions to take away from that chain:

87 / 146
  • Authentication first, authorisation last: AuthorizationFilter at the tail (formerly FilterSecurityInterceptor) is the only step that decides whether the request passes
  • 401 versus 403 depends purely on "who are you": no identity at all → AuthenticationEntryPoint answers 401; identity without the permission → AccessDeniedHandler answers 403
  • When you need custom authorisation, extend that chain instead of stacking a second LoginInterceptor on top — otherwise one request is judged twice, and when it goes wrong you cannot tell who blocked it
88 / 146
Section
14. Lab four: which station does the request die at
89 / 146

Section 13 was about who blocked it; this one is about when. The same URL can fail in three different places — handler found but nobody can execute it, parameters that cannot be filled, an exception nobody takes — and each produces a completely different answer. First pin the interceptor onto the dispatch line:

90 / 146
Kernel lab
TeaVMThe dispatch line again: pin the interceptor to a stationidle
Compare the three paths and note which stations a request reaches before it fails
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
91 / 146

Now match the three paths against what preHandle actually sees:

92 / 146
Table
RequestWhat happens on the dispatch lineThe interceptor's point of view
/users/42hits @GetMapping("/users/{id}") → resolves "42" into Long → reflective call → writes JSONhandler is a HandlerMethod, so the annotation on the method is readable
/usershits the list method, no path variable neededSame, but a different HandlerMethod — this is why annotation-based whitelisting works at all
/nopegetHandler returns null → noHandlerFound → 404The interceptor never runs: no handler means no interceptor chain
93 / 146

Then look at where exceptions land. An exception from preHandle, from argument resolution, or from the controller all travel one and the same resolver chain, but they are picked up by different resolvers and end in different status codes:

94 / 146
Kernel lab
TeaVMThe four error texts of this area, up closeidle
Try handler, then status and convert, to match each message to the layer that produced it
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
95 / 146
  • Key point: if you want a uniform JSON for "not signed in", the exception has to enter this chain — which is exactly why Section 5 recommends throwing over returning false: a return false request never gets here
  • The last parameter, none, is the cell beginners least expect: when all three resolvers return null the exception escapes, Tomcat's error-page mechanism catches it, and the client receives a Whitelabel page instead of your error body
96 / 146

You can also get the interception right and still lose on the shape of the return value: the same return "redirect:/login" is a view name in an @Controller and a JSON string in a @RestController. Click all four cases, missing in particular — that one is the real body of "the browser lands on a blank page after being rejected":

97 / 146
Kernel lab
TeaVM@Controller versus @RestController: what the string you return is taken to meanidle
Go view → json → missing → string: see how writing a redirect, writing JSON, or writing nothing inside preHandle land on three different response shapes
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
98 / 146
Section
14.1 Kernel console: type the ordering out yourself
99 / 146

The labs watch the kernel run; the console is you driving it. This is a real container and every line of output is computed by the Java kernel in your browser — use beans to confirm the filters and interceptors were actually registered, then lab to replay each scene above:

100 / 146
Console
101 / 146

If beans does not list your LoginInterceptor, the reason is usually not a missing @Component: the registration method was never called. A WebMvcConfigurer living outside the main class's package means addInterceptors is silently skipped. Commands 3 and 4 are the Section 5 fork intype-it-yourself form: same handler, order gives you all nine stations, pre gives you "everything from here disappears".

102 / 146
Section
15. Common errors, searchable by exact wording
103 / 146

The four columns below are fixed: error fragment / real cause / 30-second fix / where to dig. Copy the error text verbatim into a search engine — engines match fully qualified class names and whole English sentences, not your description of them.

104 / 146
Table
Error text (fragment)Real cause30-second fixDig deeper
Endpoint returns HTTP 200 with an empty body, the front end gets no message at allpreHandle returned false, so doDispatch stops there: no controller, no HandlerExceptionResolver, and the status header stays at its default 200Throw and let @RestControllerAdvice answer; if you really must return false, write the body yourself with response.getWriter().write(...) and flush itSection 5
Browser console: Access to fetch at '...' has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.The response is missing Access-Control-Allow-Origin: CORS was configured on an interceptor or with @CrossOrigin, while the request had already been rejected by an earlier auth filter, so the header-adding code never ranMove CORS into the earliest filter (Ordered.HIGHEST_PRECEDENCE), answer OPTIONS before anything else, and rule out "the interceptor blocked the preflight"Section 12 (cors)
Startup fails outright: IllegalArgumentException: When allowCredentials is true, allowedOrigins cannot contain the special value "*"allowCredentials=true (cookies) combined with a wildcard origin — the spec forbids it, because Access-Control-Allow-Origin cannot be both * and credentialedList the origins explicitly, or use allowedOriginPatterns("*"); never leave both unset in production just to silence the messageSection 12 (cors)
In preHandle you want to check "does this method carry @NeedLogin" and the cast fails, or the annotation is always nullThe handler parameter is not always a HandlerMethod: static resources give ResourceHttpRequestHandler, the error dispatch gives ParameterizableViewControllerOpen with if (!(handler instanceof HandlerMethod hm)) return true; to pass non-method handlers through, then read the annotationSection 9, plus the trace below
A filter logs the request body, and the controller's @RequestBody is then null or fails with HttpMessageNotReadableException: Required request body is missing (or Stream closed)The body is a one-shot byte stream: once request.getInputStream() is drained there is nothing left for Spring to deserialiseWrap the request with ContentCachingRequestWrapper or your own HttpServletRequestWrapper so it can be re-read; do it in the filter layer, never in an interceptorSection 12 (scope)
A filter annotated with @Component "just never runs"It is actually @WebFilter without @ServletComponentScan; or it is registered but its urlPatterns do not match the real pathRegister it with FilterRegistrationBean and an explicit addUrlPatterns("/*"), and log on the first line of doFilter to prove it was calledSection 3.1
You added an interceptor, and now the login page plus all CSS / JS answer 401The interceptor pattern is /**, which also captures static resources and the error dispatch; intercepting /error turns a real exception into a blank pageexcludePathPatterns("/static/", "/favicon.ico", "/error", "/actuator/"), explicitlySection 9
Asynchronous endpoints (@Async, CompletableFuture) have no traceId in their logs, or someone else'sMDC is a ThreadLocal and pooled threads are reused; the async task is not on the original thread, so it cannot see the parent contextMDC.getCopyOfContextMap() before submitting, setContextMap(...) before running, or wrap it once with a TaskDecoratorSection 8
105 / 146
Note

rows two and three are two different failures — the second is the browser refusing at runtime, the third is Spring refusing at startup. Check whether the application can start before you open the browser console; doing it the other way round wastes an afternoon.

106 / 146

The fourth row — fine in testing, a 500 as soon as somebody opens the homepage — is the best stack to practise reading. It is long, but only one line is guilty:

107 / 146
Triage
Error triageClassCastException: ResourceHttpRequestHandler -> HandlerMethod
The stack behind "fine in testing, 500 on the homepage"

Your login interceptor passes every API test. Then somebody opens the homepage in a browser: the page turns into a 500 and the log fills with a long stack, although you did not change a single line of code.

java.lang.ClassCastException: class org.springframework.web.servlet.resource.ResourceHttpRequestHandler cannot be cast to class org.springframework.web.method.HandlerMethod
at com.example.web.LoginInterceptor.preHandle(LoginInterceptor.java:31)
at org.springframework.web.servlet.HandlerExecutionChain.applyPreHandle(HandlerExecutionChain.java:59)
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:1060)
at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:965)
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
108 / 146
Tip

search only the first segment after the colon (for example cannot be cast to). A full sentence matches worse, because the class names at the end vary between Spring versions.

109 / 146
Section
15.1 Config generator: switch on everything you need while triaging
110 / 146

Half of the failures above are caused by not being able to see the scene: a static resource caught by the chain, a body read empty, a 404 that never reaches an interceptor. Generate the triage-time configuration instead of typing it out by hand:

111 / 146
Generator
GeneratorTriage-time configuration for the interception layersapplication.yml2 / 4
Tick "server" and "logging" first to see how error.* and org.springframework.web expose which handler was matched; then tick "upload", because multipart endpoints are exactly where a filter that reads the body goes wrong; finish with "profile" so all of it applies to dev only
Output
server:
  port: 8080
  servlet:
    encoding: { charset: UTF-8, enabled: true, force: true }
  compression: { enabled: true, min-response-size: 2048 }

spring:
  application:
    name: demo-service

logging:
  level:
    root: INFO
    com.example.demoservice: DEBUG
    org.springframework.jdbc.core.JdbcTemplate: DEBUG   # 打 SQL 与参数
  file:
    name: logs/app.log
  logback:
    rollingpolicy: { max-file-size: 50MB, max-history: 14 }
Why each choice matters
serverserver.port loses to --server.port=8081 on the command line and to the SERVER_PORT env var.
loggingLevels work per package; root=DEBUG floods you with third-party output — never in production.
112 / 146

One thing to check in the generated output: logging.level.org.springframework.web=debug prints the type of the matched handler, which is where the static-resource and error-page handlers from Sections 4 and 15 finally show themselves. Turn it back off before production.

113 / 146
Section
16. Quick quizzes
114 / 146
Quiz
Check yourselfA controller method throws a `BusinessException`. Which hooks are skipped, which still run, and where does the exception end up?
Pick one — you get feedback right away
115 / 146
Quiz
Check yourselfYou need a login check that reads a `@LoginRequired` annotation on the controller method and returns a uniform JSON error body. Which layer, and what is the decisive reason?
Pick one — you get feedback right away
116 / 146
Section
17. Sandbox: which layer does this logic belong to
117 / 146

This sandbox computes nothing; it is a job matching exercise. Pick a layer on the left and a requirement on the right, and the output tells you whether the three things that decide it are available: can it get the HandlerMethod (which class, which method), can it get the response body (before it is written), is the transaction already open.

118 / 146
Sandbox
SandboxWhich layer does this logic belong to
Result
HandlerMethod ✓ response body ✓ (you must write it) transaction ✗
if (!(handler instanceof HandlerMethod hm)) return true; // pass static resources first
#It can read annotations and pass precisely via excludePathPatterns: this is its home turf
The default choice. Remember: throw for a standard error body, and if you return false you owe the client a response
119 / 146
Hint

the right way to play this sandbox is to ask first "what does this logic need to see", then pick the layer — not "the Filter is earliest, so let us put it there". The three layers are a relay: the Filter decides whether you get in, the interceptor decides whether you are passed to this handler, AOP decides what happens before and after the method call.

120 / 146
Section
18. Exercises in three tiers
121 / 146
Section
Tier 1 · Follow along
122 / 146

Goal: run "one Filter + two Interceptors + one Advice" end to end and watch three shapes with your own eyes: the ordering, the short circuit, the exception path.

123 / 146
java
package com.example.lab.chain;import jakarta.servlet.*;import jakarta.servlet.http.*;import org.slf4j.*;import org.springframework.context.annotation.*;import org.springframework.core.Ordered;import org.springframework.stereotype.*;import org.springframework.web.filter.OncePerRequestFilter;import org.springframework.web.servlet.*;import org.springframework.web.servlet.config.annotation.*;import java.io.IOException;import java.util.UUID;@Component@Order(Ordered.HIGHEST_PRECEDENCE)class TimingFilter extends OncePerRequestFilter {        // OncePerRequestFilter: exactly one run per request    private static final Logger log = LoggerFactory.getLogger(TimingFilter.class);    @Override    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)            throws ServletException, IOException {        long start = System.currentTimeMillis();        req.setAttribute("traceId", UUID.randomUUID().toString().substring(0, 8));        try {            chain.doFilter(req, res);                     // <- only after this line does DispatcherServlet get a turn        } finally {            log.info("[FILTER] {} {} status={} cost={}ms trace={}", req.getMethod(),                    req.getRequestURI(), res.getStatus(), System.currentTimeMillis() - start,                    req.getAttribute("traceId"));        }    }}@Componentclass AuthInterceptor implements HandlerInterceptor {     // registered before the metrics interceptor    @Override    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler)            throws Exception {        if (!(handler instanceof HandlerMethod hm)) {            return true;                                  // static resources / error pages are not method handlers        }        if (hm.hasMethodAnnotation(PublicApi.class)) {            return true;                                  // annotation-based passing needs a HandlerMethod        }        if (req.getHeader("Authorization") == null) {            throw new IllegalStateException("NOT_LOGIN"); // throw -> uniform error body; false -> empty 200        }        return true;    }}@Componentclass MetricsInterceptor implements HandlerInterceptor {    private static final Logger log = LoggerFactory.getLogger(MetricsInterceptor.class);    @Override    public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {        req.setAttribute("t0", System.currentTimeMillis());        return true;    }    @Override    public void postHandle(HttpServletRequest req, HttpServletResponse res,                           Object handler, ModelAndView mv) {        log.info("[METRICS] postHandle ran for {}", handler);   // a failing request never prints this line    }    @Override    public void afterCompletion(HttpServletRequest req, HttpServletResponse res,                                Object handler, Exception ex) {        long cost = System.currentTimeMillis() - (long) req.getAttribute("t0");        log.info("[METRICS] {} cost={}ms ex={}", handler, cost, ex == null ? "none" : ex.getClass().getSimpleName());    }}@RestController@RequestMapping("/api")class LabController {    @GetMapping("/public") @PublicApi    public String open() { return "ok"; }    @GetMapping("/me")    public String me(@RequestHeader("Authorization") String token) { return "hello " + token; }    @GetMapping("/boom") @PublicApi    public String boom() { throw new RuntimeException("boom"); }}
124 / 146
java
@Configurationclass MvcConfig implements WebMvcConfigurer {    private final AuthInterceptor auth; private final MetricsInterceptor metrics;    MvcConfig(AuthInterceptor a, MetricsInterceptor m) { this.auth = a; this.metrics = m; }    @Override    public void addInterceptors(InterceptorRegistry registry) {        registry.addInterceptor(auth).addPathPatterns("/api/**")          // 1st registered = 1st preHandle                .excludePathPatterns("/api/public", "/static/**", "/error");        registry.addInterceptor(metrics).addPathPatterns("/api/**");      // 2nd registered    }}
125 / 146

Verify each case with curl after startup — every request takes a different path through the chain:

126 / 146
bash
# 1) public endpoint: whitelist hit, both interceptors pass it throughcurl -i localhost:8080/api/public# 2) no Authorization header: AuthInterceptor throws -> does postHandle appear?curl -i localhost:8080/api/me# 3) a normal request with a tokencurl -i -H "Authorization: abc123" localhost:8080/api/me# 4) the controller throws on its owncurl -i localhost:8080/api/boom
127 / 146

Expected log order for request 3:

128 / 146
text
[METRICS] postHandle ran for ...   # only a successful request prints this line[METRICS] ...LabController#me... cost=4ms ex=none[FILTER] GET /api/me status=200 cost=9ms trace=8f3c1a02
129 / 146

For requests 2 and 4 the expectation is: no postHandle ran line at all, while [METRICS] ... ex=... and [FILTER] still print. That is "postHandle is for success, afterCompletion always runs, the Filter wraps everything" proven on your own console.

130 / 146
Section
Tier 2 · Variants
131 / 146

Three edits, each one or two lines long; the point is to watch the error text and the response body change:

132 / 146
  1. Replace the throw in AuthInterceptor.preHandle with return false, then run request 2. You will see the status is still 200 with an empty body, and the [METRICS] line disappears too — because when the first interceptor short-circuits, the second never even gets a preHandle. This is the interceptor|auth cell of the sandbox in Section 17.
  2. Swap the two addInterceptor calls so metrics registers first, then run request 2 again. Now [METRICS] ... ex=IllegalStateException: NOT_LOGIN appears: the metrics interceptor was entered first, so it receives the completion callback. Conclusion: register the metrics interceptor before the auth one, or every rejected request is missing from your counters.
  3. Add req.getInputStream().readAllBytes(); before chain.doFilter in TimingFilter, then POST to an endpoint with @RequestBody. You will get HttpMessageNotReadableException: Required request body is missing, and wrapping the request in a ContentCachingRequestWrapper fixes it immediately.
133 / 146
Section
Tier 3 · Build one
134 / 146

Build a "gatekeeper + bookkeeper" pair: every write operation (POST / PUT / DELETE) must be signed in and carry X-Request-Id, and the app must be able to answer "who was blocked, at which layer".

135 / 146

Requirements:

136 / 146
  • A CorsFilter at HIGHEST_PRECEDENCE: answer OPTIONS with 200 without entering the business code; when cookies are involved use allowedOriginPatterns, never *
  • An AuditInterceptor: in preHandle test handler instanceof HandlerMethod, then put the class-level @RequestMapping prefix plus the method name into the MDC; in afterCompletion log the elapsed time and flag requests where ex != null
  • A RepeatableBodyFilter: cache the body with an HttpServletRequestWrapper so one JSON payload can be read by the filter and still be read by @RequestBody
  • A @RestControllerAdvice: turn IllegalStateException("NOT_LOGIN") into {"code":40100,"msg":"please sign in"} with the HTTP status still 401
137 / 146

Acceptance checklist:

138 / 146
  • [ ] curl -i -X OPTIONS -H "Origin: http://localhost:5173" -H "Access-Control-Request-Method: POST" localhost:8080/api/orders returns 200 and carries Access-Control-Allow-Origin — proof that CORS runs ahead of auth
  • [ ] /api/orders without Authorization answers 401 plus the uniform JSON error body — not an empty 200, not a Whitelabel page
  • [ ] The logs contain "Class#method" (proof the interceptor really received the HandlerMethod; move the same logic into CorsFilter and it immediately cannot)
  • [ ] POST a valid JSON: the controller receives the object and the filter log prints the raw body (proof the wrapper works)
  • [ ] Make the controller throw on purpose and confirm the audit line is still written (proof the bookkeeping lives in afterCompletion, not postHandle)
139 / 146
Section
19. Self-check
140 / 146
Self-check

without looking back, can you recite every station a request passes through and say which steps run in reverse? (Filter before-code forward → DispatcherServlet → preHandle forward → argument resolution and validation → controller, with AOP wrapping the call → postHandle reverse → exception resolution → afterCompletion reverse → message converter writes the body → Filter after-code reverse)

141 / 146
Self-check

after preHandle returns false, which hooks still run and which do not? (Later interceptors' preHandle, argument resolution, the controller and postHandle are all skipped; only the earlier interceptors that already returned true get afterCompletion; the one that returned false does not get it for itself)

142 / 146
Self-check

why does elapsed-time measurement belong in afterCompletion rather than postHandle? (postHandle is skipped entirely when an exception is in flight — precisely the samples you most want to see; afterCompletion has finally semantics, always runs, and carries the original exception)

143 / 146
Self-check

what are the three possible runtime types of the interceptor's handler parameter, and why must you test instanceof HandlerMethod first? (HandlerMethod, ResourceHttpRequestHandler for static resources, ParameterizableViewController for the error page; cast without testing and /favicon.ico or /error throws ClassCastException)

144 / 146
Self-check

why does adding @EnableWebMvc make pages go blank and static resources 404? (It switches off Spring Boot's MVC auto-configuration, including the default static resource mapping; for custom configuration implement WebMvcConfigurer instead of annotating with @EnableWebMvc)

145 / 146
Mnemonic

the check-in desk does not know your flight, the checkpoint does — need "which method"? the interceptor. Need raw bytes? the Filter. Need before and after a call? AOP. postHandle hands out candy only to those who succeeded; afterCompletion is the cashier who settles every bill at departure.

146 / 146
Summary

keep one sequence line plus four insights. The line: Filter before-code → DispatcherServlet → Interceptor.preHandle → Controller (with AOP inside it) → reverse hooks: postHandle → view rendering → afterCompletion → Filter after-code → client. The four insights: ① before-hooks run forward, after-hooks run reverse; ② postHandle runs only on success, afterCompletion always runs; ③ AOP cannot catch Filter / Interceptor and cannot "block a request", because it happens after the method call; ④ the three relay rather than replace — Filter controls entry, Interceptor controls passing, AOP controls before and after a method call. Burn the line and the four insights in, and "where should the interception go" will never puzzle you again.