The Spring Security Filter Chain Explained: Every Filter, In Order, and How to Debug It
The sixteen filters Spring Security 7.1 puts in your chain, their exact order numbers, where addFilterBefore actually lands, and four failure modes that produce a 401, a 500, or a filter that is in the chain and never runs. Every table printed from a running Spring Boot 4.1.1 application.
Somebody on your team adds a filter. It reads a header, builds an Authentication, puts it in the SecurityContextHolder, and calls chain.doFilter. The code is right. The credential is right. The request comes back 401.
The filter ran. You can prove it ran. It just ran one slot too late, and the authorization decision had already been taken half a millisecond earlier against an anonymous principal. Nothing logged a warning, because nothing was wrong — a filter executed in the position it was configured to execute in.
That is the whole problem with the Spring Security filter chain: it is a list of sixteen objects that decides everything about a request, and there is no ordinary way to look at it. So this post prints it. Every table below comes out of a running Spring Boot 4.1.1 application, either from the live FilterChainProxy bean or by reflecting the ordering table out of spring-security-config‘s own jar. Nothing here is transcribed from documentation, and in four places it disagrees with the documentation.
If you are here because…
Go to
You want the list of filters and what each one does
Versions. Spring Security 7.1.1, Spring Boot 4.1.1, Spring Framework 7.0.9, JDK 25 (Temurin 25.0.4.1+1), Tomcat 11.0.24. Every version was read from repo1.maven.org/…/maven-metadata.xml, not from a release announcement — 7.2.0-M1 and 4.2.0-M1 exist on Central as milestones and are not releases.
Everything Spring Security does in a web application happens inside a single filter registered with the servlet container. Ask the container what it knows about and it gives you this — printed live from ServletContext.getFilterRegistrations() by the demo application:
One entry. None of the sixteen filters that make the actual security decision appear here, and the container cannot see them.
springSecurityFilterChain is a DelegatingFilterProxy — in Boot, an anonymous subclass of one, hence the $1. The container instantiates filters long before an ApplicationContext exists and has no idea what a bean is, so DelegatingFilterProxy is registered instead and forwards to a Spring bean by name on the first request.
That bean is FilterChainProxy, and it is not a chain. It holds a list of chains. On each request it walks the list, asks each SecurityFilterChain whether its RequestMatcher matches, and invokes the first one that says yes. There is no fall-through and no combining.
FilterChainProxy also does three things nothing else does: it applies the HttpFirewall before any chain runs (which is why a RequestRejectedException seems to come from nowhere), it clears SecurityContextHolder in a finally block so a pooled thread does not carry someone else’s identity, and it wraps every filter in a decorator. With Micrometer on the classpath — Boot’s actuator starter pulls it in — that decorator is ObservationFilterChainDecorator, and it puts three frames between every pair of real filters in every stack trace you will ever read.
The sixteen filters, in order
Here is a configuration that turns on four things:
Turns AuthenticationException / AccessDeniedException into 401 / 403 / a redirect
Denials surface as 500
16
4200
AuthorizationFilter
Runs authorizeHttpRequests and denies
Nothing is authorized
Three things about that list are worth saying out loud, because the commonly circulated version of it is wrong in all three.
It is sixteen, not fifteen. The sample in the Spring Security reference documentation lists fifteen and omits DefaultResourcesFilter. That same sample shows the startup log as Will secure any request with [ … ]. What 7.1.1 actually prints is:
DEBUG o.s.s.web.DefaultSecurityFilterChain : Will secure any request with filters:
DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, ...
with filters:, not with [. I checked the constant pool of DefaultSecurityFilterChain in 6.5.1, 7.0.7 and 7.1.1 — all three use the newer wording, so the documented sample is older than any currently supported version. If you grep your logs for the bracketed form you will find nothing.
SessionManagementFilter is not in the default chain. Neither is SecurityContextPersistenceFilter. Plenty of articles list both. SessionManagementFilter shows up only when you configure sessionManagement(…) explicitly — and here is the counter-intuitive part, asking for SessionCreationPolicy.STATELESSadds a filter at 3900 rather than removing one. Statelessness is enforced by a filter, not by the absence of one. SecurityContextPersistenceFilter was superseded by SecurityContextHolderFilter in 6.0.
Filter 3 does not read the session.SecurityContextHolderFilter installs a SupplierDeferredSecurityContext; the session read happens on first dereference. Watch where the session lookup is logged in a real request:
TRACE FilterChainProxy : Invoking SecurityContextHolderFilter (3/16)
...
TRACE FilterChainProxy : Invoking BasicAuthenticationFilter (11/16)
TRACE BasicAuthenticationFilter : Found username 'alice' in Basic Authorization header
TRACE HttpSessionSecurityContextRepository : No HttpSession currently exists
TRACE SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
Under filter 11. That is a feature — a request that never touches the context never touches the session — and it is the first of three reasons the TRACE log is not the timeline it looks like.
Four events, and that is the whole model. The context becomes available (filter 3), the request is protected from exploits (4–5), the request is authenticated (6–14), the request is authorized (16, with 15 standing by to translate the refusal). Where a custom filter belongs is entirely a question of which of those four have already happened by the time it runs. Everything else in this post is a consequence of that sentence.
Where the order comes from
Not from your configuration. Moving .csrf(…) below .httpBasic(…) changes nothing, because HttpSecurity.performBuild() ends with:
this.filters.sort(OrderComparator.INSTANCE);
The sort key is a hard-coded table in a package-private class, FilterOrderRegistration, built once in its constructor:
Step order = new Step(100, 100); // INITIAL_ORDER, ORDER_STEP
put(DisableEncodeUrlFilter.class, order.next()); // 100
put(ForceEagerSessionCreationFilter.class, order.next()); // 200
// ...
Step.next() returns the current value and then adds the step, so slots run 100, 200, 300 … with ninety-nine free numbers between neighbours. There is no supported way to read that table, so the companion repository reads it by reflection rather than transcribing it — if a future version renumbers a slot, the printed table changes with it:
Class<?> type = Class.forName(
"org.springframework.security.config.annotation.web.builders.FilterOrderRegistration");
Constructor<?> constructor = type.getDeclaredConstructor();
constructor.setAccessible(true);
Field field = type.getDeclaredField("filterToOrder");
field.setAccessible(true);
Map<String, Integer> table = (Map<String, Integer>) field.get(constructor.newInstance());
Forty-one registered slots, two reserved gaps, 100 through 4300:
The map is keyed by class-name string, not by Class, because many of its entries live in optional modules — oauth2-client, saml2-service-provider, cas, oauth2-resource-server — that may not be on the classpath. Registering by name lets the table mention them without failing to load.
Two entries are different. ChannelProcessingFilter (300) and FilterSecurityInterceptor (4100) are org.springframework.security.web classes, and spring-security-webis on the classpath. The classes are not in it:
Both were removed in Spring Security 7.0. Their slots stayed in the table so that no other number had to move — the 6.5.1 and 7.1.1 tables are otherwise identical.
This is a compile error, not a runtime one. Every pre-7 tutorial that ends with http.addFilterBefore(myFilter, FilterSecurityInterceptor.class) stops compiling on 7.x. The replacement anchor is AuthorizationFilter.class, one slot later at 4200 — so a filter that used to sit at 4099 now sits at 4199. If you had anything between them, check it.
Resolution walks up the superclass chain
getOrder(Class) does not do a lookup and give up. On a miss it takes getSuperclass() and tries again:
for (Class<?> type = filterType; type != null; type = type.getSuperclass()) {
Integer order = table.get(type.getName());
if (order != null) return order;
}
return null;
That is the entire reason the one-argument http.addFilter(myFilter) exists. A subclass of UsernamePasswordAuthenticationFilter inherits slot 2100 and needs no anchor; a subclass of AbstractPreAuthenticatedProcessingFilter inherits 1700.
OncePerRequestFilter is not in the table, and that is what almost everyone extends. So the one-argument form fails on almost every filter anyone actually writes:
IllegalArgumentException: The Filter class com.ankurm.chain.filter.RequestIdFilter does not
have a registered order and cannot be added without a specified order. Consider using
addFilterBefore or addFilterAfter instead.
(That message is read out of HttpSecurity‘s constant pool, not paraphrased.)
Where custom filters land
All three anchored forms go through one private method, and it is seven lines long:
private HttpSecurity addFilterAtOffsetOf(Filter filter, int offset,
Class<? extends Filter> registeredFilter) {
Integer registeredFilterOrder = this.filterOrders.getOrder(registeredFilter);
if (registeredFilterOrder == null) { throw new IllegalArgumentException(...); }
int order = registeredFilterOrder + offset; // -1 before, 0 at, +1 after
this.filters.add(new OrderedFilter(filter, order));
this.filterOrders.put(filter.getClass(), order); // your filter is now an anchor too
return this;
}
Four consequences follow, and none of them are documented.
The offset is exactly ±1.addFilterBefore(f, CsrfFilter.class) gives 1099. Not 1050, not “somewhere between HeaderWriterFilter and CsrfFilter”. 1099.
Your filter becomes an anchor. The penultimate line registers your class at the order it just computed, so chaining works: addFilterAfter(new ApiKeyFilter(), LogoutFilter.class) then addFilterAfter(new AuditFilter(), ApiKeyFilter.class) gives 1201 and 1202. It also means adding two instances of the same class against different anchors leaves the table pointing at whichever registration happened last.
The anchor does not have to be in the chain.getOrder consults the static table, not the chain being built. The demo’s tie profile calls csrf().disable() and then still adds filters beforeCsrfFilter.class:
4/12 HeaderWriterFilter order=900
5/12 MarkerFilterA order=? (not in the registration table)
6/12 MarkerFilterB order=? (not in the registration table)
7/12 LogoutFilter order=1200
There is no CsrfFilter anywhere in that chain. The markers still land on 1099, between 900 and 1200. Convenient — and a trap, because “before the CSRF filter” stops meaning anything the moment someone removes it.
Two filters on one anchor get the same number. Both of these produce 1099:
List.sort on an ArrayList is a stable sort, so equal orders keep insertion order. Flip the two calls and the execution order flips with them, with the order numbers unchanged:
--- A registered first ---
executed order: [A, B]
--- B registered first (-DTIE_REVERSED=true), nothing else changed ---
executed order: [B, A]
That guarantee comes from java.util.List, not from Spring Security. Nothing in the API promises it. If the relative order of two custom filters matters to you, anchor the second to the first.
headers and CSRF have run; nothing has authenticated yet
authorization, or anything that throws AccessDeniedException
ExceptionTranslationFilter
4001
the principal is settled and the throw will be translated
That last row is where this post disagrees with the reference documentation, which suggests AnonymousAuthenticationFilter (3701) for authorization filters. Which brings us to the failures.
Four ways a chain goes wrong
The happy path is four lines of configuration and every other blog has it. The days people lose are here.
An authentication filter one slot too late
This is the 401 from the opening paragraph. Same ApiKeyAuthenticationFilter that works perfectly at 1201, anchored to AuthorizationFilter instead of LogoutFilter:
11/12 AuthorizationFilter order=4200
12/12 ApiKeyAuthenticationFilter order=? (not in the registration table)
$ curl -i -H "X-Api-Key: let-me-in" localhost:8080/whoami
HTTP/1.1 401
The key is valid. The filter is in the chain. The filter runs — it is the last thing that runs. It sets the SecurityContext immediately after the authorization decision was taken against the anonymous principal.
Fingerprint. A 401 or 403 with a credential you know is good, no exception anywhere, and a TRACE log showing your filter invoked afterAuthorizationFilter (n/n). The fix is one word: anchor authentication filters to LogoutFilter, not to anything near the bottom of the chain.
A denial that comes back as 500
ExceptionTranslationFilter is what turns AccessDeniedException into a 403 and AuthenticationException into a 401 or a login redirect. It does that by wrapping the rest of the chain in a try/catch. A filter that throws from below order 4000 is not inside that try block.
The companion application puts two byte-identical TenantFilters either side of the boundary. Both throw AccessDeniedException:
$ curl -i -u alice:password localhost:8080/tenant/doc # filter at order 3701
HTTP/1.1 500
$ curl -i -u alice:password localhost:8080/tenant/translated # filter at order 4001
HTTP/1.1 403
Order 3701 is addFilterAfter(…, AnonymousAuthenticationFilter.class) — the placement the reference documentation’s own TenantFilter example uses, in an example whose filter throws AccessDeniedException. Follow it literally and you get a 500.
The filter that is there and does not run
Getting the transcript above required a JVM flag, and the reason cost a test failure to find.
OncePerRequestFilter guards against double execution using a request attribute named getFilterName() + ".FILTERED". For a filter that is not a Spring bean, getFilterName() falls back to the class name. Two instances of the same subclass therefore answer the same attribute name, and the second one to run finds it already set and skips itself:
$ curl -i -u alice:password localhost:8080/tenant/translated
HTTP/1.1 200 <- both TenantFilters sharing one already-filtered key
HTTP/1.1 403 <- with -DUNIQUE_ONCE_KEY=true
Both filters are in the chain either way — the diagnostic dump lists TenantFilter at positions 17 and 19 of 20. One of them is inert, and there is no log line for it at any level.
If a filter is visibly in the chain and visibly does nothing, this is usually why. Override getAlreadyFilteredAttributeName(), give each instance its own class, or register them as beans with distinct names. The same mechanism has a second face: on an ERROR dispatch, skipDispatch() makes every OncePerRequestFilter skip itself, because shouldNotFilterErrorDispatch() returns true by default.
One bean, two registrations
Spring Boot registers every jakarta.servlet.Filterbean with the servlet container. Add that same bean to the security chain and it is in two chains at once — the container’s, wrapping FilterChainProxy, and Spring Security’s, inside it:
--- a plain @Bean filter, also added to the chain ---
X-Counting-Filter-Invocations: 2
countingFilter com.ankurm.chain.filter.CountingFilter urls=[/*]
--- the same bean, wrapped in a disabled FilterRegistrationBean ---
X-Counting-Filter-Invocations: 1
(not registered with the container)
@Bean
FilterRegistrationBean<CountingFilter> disableContainerRegistration(CountingFilter filter) {
FilterRegistrationBean<CountingFilter> registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false); // container only; the bean still joins the chain
return registration;
}
Extending OncePerRequestFilterhides this rather than fixing it. The second pass short-circuits, the counter reads 1, and you conclude there is no problem — while the filter is still registered twice and still wraps every request the container serves, including paths no SecurityFilterChain matches.
Reading the TRACE output
logging.level.org.springframework.security=TRACE
One line, and worth more than every diagram of the filter chain including the two in this post. Five lines in the resulting output carry the information.
TRACE FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as
'baseline' in [class path resource [com/ankurm/chain/config/BaselineSecurityConfig.class]]
matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, ...] (1/1)
DEBUG FilterChainProxy : Securing GET /whoami
TRACE FilterChainProxy : Invoking CsrfFilter (5/16)
...
DEBUG FilterChainProxy : Secured GET /whoami
The first is the one almost nobody knows exists: it names the bean and the class that declared it. With four SecurityFilterChain beans and a request behaving as though it hit the wrong one, that single grep settles it. The (n/m) counters on the Invoking lines are positions in the same list getFilterChains() returns, so they line up exactly with the tables above. Secured means the chain completed and handed off to the servlet — it does not mean the request succeeded.
And then three ways the log lies about time.
1. Filters log lazily. The end of a successful request looks like this:
TRACE FilterChainProxy : Invoking AnonymousAuthenticationFilter (14/16)
TRACE FilterChainProxy : Invoking ExceptionTranslationFilter (15/16)
TRACE FilterChainProxy : Invoking AuthorizationFilter (16/16)
TRACE RequestMatcherDelegatingAuthorizationManager : Authorizing GET /whoami
TRACE RequestMatcherDelegatingAuthorizationManager : Checking authorization on GET /whoami ...
TRACE AnonymousAuthenticationFilter : Did not set SecurityContextHolder since already authenticated ...
AnonymousAuthenticationFilter logs its decision two filters after it was invoked. Not a threading artefact — it installs a lazily-resolved supplier, and the supplier runs when something first asks for the authentication, which here is AuthorizationFilter. Same mechanism as the session read appearing under filter 11 earlier.
2. SupplierDeferredSecurityContext : Created … appears two or three times per request. It is not two contexts and it is not a leak; the class logs on each getContext() that finds nothing stored.
3. A rejected request runs the whole chain twice. This is the big one. Here is a POST with no CSRF token:
TRACE FilterChainProxy : Invoking CsrfFilter (5/16)
TRACE CsrfTokenRequestHandler : Did not find a CSRF token in the [X-CSRF-TOKEN] request header
TRACE CsrfTokenRequestHandler : Did not find a CSRF token in the [_csrf] request parameter
DEBUG CsrfFilter : Invalid CSRF token found for http://localhost:8080/hello
DEBUG AccessDeniedHandlerImpl : Responding with 403 status code
DEBUG o.a.c.c.C.[Tomcat].[localhost] : Processing ErrorPage[errorCode=0, location=/error]
TRACE FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'baseline' ...
DEBUG FilterChainProxy : Securing GET /error
TRACE FilterChainProxy : Invoking DisableEncodeUrlFilter (1/16)
...
TRACE FilterChainProxy : Invoking AuthorizationFilter (16/16)
TRACE RequestMatcherDelegatingAuthorizationManager : Authorizing GET /error
TRACE AnonymousAuthenticationFilter : Set SecurityContextHolder to AnonymousAuthenticationToken ...
TRACE ExceptionTranslationFilter : Sending AnonymousAuthenticationToken ... to authentication
entry point since access is denied
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied
The container dispatches to /error and the entire sixteen-filter chain runs again for that dispatch, counters restarting from (1/16). It runs again because Boot registers springSecurityFilterChain for every dispatcher type — SecurityFilterProperties.dispatcherTypes defaults to EnumSet.allOf(DispatcherType.class), and spring.security.filter.dispatcher-types=request is how you stop it. Two further details make this worth understanding rather than merely noticing.
Half the filters invoke and immediately return. OncePerRequestFilter.doFilter begins with skipDispatch(request), which is true when the request carries the jakarta.servlet.error.request_uri attribute and shouldNotFilterErrorDispatch() agrees — and that method returns true by default. Six of the sixteen default filters extend OncePerRequestFilter: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, HeaderWriterFilter, CsrfFilter, DefaultLogoutPageGeneratingFilter and BasicAuthenticationFilter. The other ten extend GenericFilterBean and run in full. You can see it in the transcript — on the second pass LogoutFilter still logs its path check while BasicAuthenticationFilter (11/16) logs nothing at all.
Which means the error dispatch is authorized but not authenticated. AuthorizationFilter extends GenericFilterBean and runs; BasicAuthenticationFilter extends OncePerRequestFilter and does not. GET /error is matched against authorizeHttpRequests on its own merits as an anonymous request, and under .anyRequest().authenticated() it is denied.
This is the source of a whole family of confusing reports. A 403 with a 401 logged underneath it. An /error page that itself 403s. A custom filter that “runs twice, but only for failed requests”. Permitting /error explicitly is the usual fix, and it is much better done deliberately than discovered at 2am.
$ grep 'Trying to match request against' app.log # which chain, and which bean declared it
$ grep 'Will secure' app.log # the chain as built, one line per chain
$ grep 'Invoking' app.log | tail -30 # the chain as it actually ran
More than one chain
Three SecurityFilterChain beans in one application, and the interesting number is the last column:
Chain
@Order
Matcher
Filters
API — stateless, no CSRF, Basic only
1
/api/**
12
Diagnostics and public
2
/diag/**, /public/**
10
Browser — sessions, CSRF, form login
3
any request
15
The API chain drops CsrfFilter, UsernamePasswordAuthenticationFilter, DefaultResourcesFilter and both default page generators — five filters that mean nothing to a token client and five filters that cannot surprise you later. A stateless API served by the browser chain is where most “why is my POST getting a 403” questions come from.
Two things that read alike and are not:
http.securityMatcher(…) decides whether this chain handles the request at all. It is the chain’s RequestMatcher, evaluated by FilterChainProxy before any filter runs.
authorizeHttpRequests(a -> a.requestMatchers(…)) decides what AuthorizationFilter does once the chain is already running.
A chain with no securityMatcher matches everything. Two of those and the second is dead code. WebSecurityFilterChainValidator catches the blatant case at startup:
UnreachableFilterChainException: “A filter chain that matches any request […] has already been configured, which means that this filter chain […] will never get invoked. Please use HttpSecurity#securityMatcher to ensure that there is only one filter chain configured for ‘any request’ and that the ‘any request’ filter chain is published last.”
It runs three checks and none of them catch the common case: two specific matchers that overlap partially, with the broader one declared first. Nothing warns about that. Order chains explicitly with @Order, narrowest first — a bean with no @Order gets LOWEST_PRECEDENCE, and two of those have no defined order relative to each other at all.
ignoring() is not a faster permitAll()
WebSecurity.ignoring() produces a genuine SecurityFilterChain containing zero filters:
Spring Security warns about it at startup and the warning is worth heeding: the saving is a few microseconds of filter dispatch, and the cost is that the path is outside Spring Security entirely — including the HttpFirewall.
The long tail
Each of these is reproducible in the companion repository, one link each.
Two filters added before the same anchor share an order number; the tie is broken by List.sort being stable — a java.util.List guarantee, not a Spring Security one — chapter 04
http.addFilter(f) without an anchor only works if some superclass of f is in the order table — chapter 03
Orders 500 and 2300 are reserved and hold nothing at all — the reflected table
Every stack trace gains three ObservationFilterChainDecorator frames between each pair of filters when Micrometer is present — chapter 01
SessionCreationPolicy.STATELESSaddsSessionManagementFilter at 3900 — demo 10
Every Basic authentication in 7.x carries a FactorGrantedAuthority[FACTOR_PASSWORD] alongside your roles, which breaks containsExactly assertions — demo 3
How to assert all of this in tests, and why containsExactly on the whole chain is the right kind of brittle — chapter 09
Boot 4 moved SecurityProperties.DEFAULT_FILTER_ORDER and BASIC_AUTH_ORDER to a new class, SecurityFilterProperties, in a new package; values unchanged, IGNORED_ORDER gone entirely — chapter 01
A symptom → cause table for the whole chain — chapter 08
The forty lines worth stealing
Everything printed in this post that is not a log line came from one controller:
@RestController
public class DiagnosticsController {
private final FilterChainProxy filterChainProxy;
@GetMapping(value = "/diag/chains", produces = "text/plain")
public String chains() {
StringBuilder out = new StringBuilder();
for (SecurityFilterChain chain : this.filterChainProxy.getFilterChains()) {
List<Filter> filters = chain.getFilters();
out.append(chain).append(" (").append(filters.size()).append(" filters)\n");
int i = 0;
for (Filter filter : filters) {
out.append(String.format(" %2d/%d %s%n", ++i, filters.size(),
filter.getClass().getSimpleName()));
}
}
return out.toString();
}
}
It reads the live bean, so it cannot disagree with reality the way a diagram can, and the numbering matches the (n/m) counters in the TRACE log because both come from the same list. Copy it into whatever you are debugging.
And then delete it. That endpoint publishes your entire security topology — which paths have which chains, how many filters each one has, which authentication mechanisms are wired up. It belongs behind a profile that is never active in production, or better, in a branch you never merge. The version in the companion repository is deliberately reachable because the whole point of that project is to look at it; that is not a pattern to copy wholesale.
Should you be writing a filter at all?
Probably not. Most of what people reach for a custom filter to do has a better home:
Per-request authorization logic belongs in an AuthorizationManager registered through authorizeHttpRequests, not a filter. You get the denial translated for free and you get it in the same place as every other rule.
A new credential format is usually an AuthenticationProvider or an AuthenticationConverter plugged into the existing AuthenticationFilter (3200), not a filter of your own.
Correlation ids, logging, metrics have no security opinion and belong in the servlet chain, where they also wrap the requests FilterChainProxy rejects outright.
A custom filter is the right answer when you need to act on the raw request or response before or after security has an opinion — tenancy resolution, a header your gateway sets, a response wrapper. If that is you, the placement table above is the whole job. If it is not, you are about to inherit every failure mode in this post for something the framework already does.
No Comments yet!