back to blog
Aug 30, 2026

How Spring Security Actually Blocks a Request: Filter Chains and Interceptors, Demystified

What I learned digging into Spring Security's filter chain and interceptors — how a request gets stopped before it ever reaches your controller.

JavaSpringSpring Security

Why I Went Down This Rabbit Hole

The first time I added Spring Security to a project, I regretted it almost immediately. One dependency, and suddenly every endpoint returned a login page I never asked for. I added a config class, it half-worked, I had no idea why, and the official docs throw around terms like "filter chain," "authentication," and "security context" like you're already supposed to know what those mean. I didn't. I just wanted my endpoint to stop redirecting to /login.

That confusion didn't go away until I stopped treating it as one big black box and looked at what it actually is underneath: a chain of plain Servlet Filters, run one after another. Once I saw it as a list instead of a monolith, debugging security stopped being scary and started being almost boring.

The Request Doesn't Go Straight to Your Controller

Your @RestController method is one of the last things a request touches, not the first. Before DispatcherServlet routes anything, the request passes through Spring Security's FilterChainProxy — a filter that picks the right SecurityFilterChain for this URL and runs every filter in it, in order.

Client request
  -> FilterChainProxy
       -> SecurityFilterChain (ordered list of filters)
  -> DispatcherServlet
  -> HandlerInterceptor(s)
  -> Controller

What's Inside a SecurityFilterChain

This is the default chain Spring Boot wires up, in order. Full details are in the Spring Security docs — here's the short version:

  1. DisableEncodeUrlFilter — disables URL-based session tracking.
  2. WebAsyncManagerIntegrationFilter — carries SecurityContext into async requests.
  3. SecurityContextHolderFilter — loads SecurityContext at request start, clears it after.
  4. HeaderWriterFilter — adds security response headers.
  5. CorsFilter — handles CORS, if configured.
  6. CsrfFilter — rejects requests with a missing/invalid CSRF token.
  7. LogoutFilter — clears the session on logout.
  8. UsernamePasswordAuthenticationFilter — starts a login attempt on the login URL.
  9. (Other auth filters slot in here if enabled — Basic, OAuth2/JWT, etc.)
  10. RequestCacheAwareFilter — replays the original URL after a login redirect.
  11. AnonymousAuthenticationFilter — fills in a placeholder "anonymous" user if nothing else authenticated.
  12. ExceptionTranslationFilter — turns auth exceptions into a redirect, 401, or 403.
  13. AuthorizationFilter — final gatekeeper, checks the request against your access rules.

Most of these are housekeeping or only matter if you're using that feature. The core loop worth remembering is: context loading (3) → authentication (8) → exception handling (12) → authorization (13).

Each filter calls chain.doFilter() to pass control on. Any filter can just not call it:

if (!isAuthenticated(request)) {
    response.sendError(HttpServletResponse.SC_UNAUTHORIZED);
    return;
}
chain.doFilter(request, response);

That's the whole blocking mechanism. There's no separate "reject" API — a filter blocks a request by simply not passing it along.

Authentication vs. Authorization

  • Authentication — who are you? Handled early, populates a SecurityContext.
  • Authorization — are you allowed here? Handled by AuthorizationFilter, near the end:
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers("/public/**").permitAll()
    .anyRequest().authenticated()
);

This builds a list of matcher-to-rule pairs, checked in order per request. First match wins. Deny by default if nothing matches.

Filters vs. Interceptors

Not the same layer:

  • Filters — raw Servlet API, run before DispatcherServlet, don't know what a controller is. This is where Spring Security lives.
  • Interceptors — Spring MVC concept, run after a handler is chosen, via preHandle / postHandle / afterCompletion.
public boolean preHandle(HttpServletRequest req, HttpServletResponse res, Object handler) {
    return true;
}

By the time an interceptor runs, security has already decided. Interceptors are for logging or headers, not authorization.

If You Know Express

Coming from Express, this was the most overwhelming part. Express gives you nothing by default — no filters, no login page, no redirects, just whatever middleware you write. Spring Security gives you all thirteen filters above out of the box, active the moment you add the dependency, whether you asked for them or not.

next() and chain.doFilter() do the same job — "move on to the next thing":

if (!isAuthenticated(req)) return res.status(401).send("Unauthorized");
next();

Not calling it is the whole block, in both worlds. The difference is how much is handwritten. In Express you order every middleware yourself. In Spring Security the chain is mostly assembled from config — you're arranging framework filters, not writing them from scratch.

Also, Express doesn't really split filters and interceptors — middleware and route handlers share one flat pipeline. Spring splits that into two layers, which is why the distinction above needed its own section.

Debugging a Rejected Request

  1. Is there even an Authentication in the context, or is it empty?
  2. If there is one, does it have the right authority? ("ADMIN" vs Spring's expected "ROLE_ADMIN" is a classic gotcha.)
  3. Did an earlier filter reject it first (bad CSRF token, expired session) before authorization was ever checked?

Just trace which filter stopped calling chain.doFilter().

Where My Understanding Still Breaks Down

Method-level security (@PreAuthorize) is a separate, AOP-based mechanism that runs inside the controller/service call, not in the filter chain. How it interacts with filter-level rules on the same endpoint still trips me up — that's next.

What This Taught Me

  • A request can be stopped long before your code runs, just by never calling chain.doFilter().
  • Filters and interceptors are different layers — filters see raw requests before routing, interceptors see them after.
  • "Deny by default" is a config decision: rules are matched once per URL, and what happens on no-match is what actually protects you.