Spring Security Internals: Filter Chain, Authentication, Authorization
What Spring Security does fits in one sentence: before every request reaches your business code, it inserts a row of checkpoints that asks "who are you" first and "what may you do" second, and only lets the request through when both answers pass. It is not an annotation and not an interceptor — it is one whole Servlet filter chain, and that single fact explains every trap that follows: why a request is blocked before it ever reaches a controller, why 401 and 403 surface from two different places, and why one line of configuration can affect the entire application.
Six terms, one line each (used throughout):
- Authentication: proving "you are who you claim to be"; its output is an
Authenticationobject carrying the identity and its authorities - Authorization: proving "you may do this thing", judged against the authorities produced by the previous step
- Filter: a request interceptor from the Servlet spec that runs before controllers, which is what lets it act ahead of your business code
- SecurityContext: the per-request identity holder, backed by a
ThreadLocaland cleared when the request ends - 401 / 403: 401 = not authenticated (or invalid credentials); 403 = authenticated but not allowed; two different handlers write them
- SecurityFilterChain: the configuration object for one security chain; several chains may coexist and are matched in
@Order
the whole article is a residential compound's gate, a parcel inspection desk, and a key. The gate card is authentication — a face scan or a password proving you live here. The inspection desk is authorization — you are a resident, but whether the parcel contains something forbidden, and which shelf level you may reach, is a separate check. The key is the Authentication inside the SecurityContext: "who you are and which floors you may enter", handed to you by the guard (SecurityContextHolderFilter) at the gate and collected on the way out. All three are needed: without a card (unauthenticated) you never get inside; inside without permission (authenticated but unauthorized) you stop at the stairs — that is the difference between 401 and 403, and exactly why the line between them must be drawn.

That flow is the spine of the article: the upper half is "question one · who are you", the lower half "question two · what may you do". Section 3 walks the upper half, Section 5 the lower one, and Section 7 is devoted to what each gate's failure looks like (401 versus 403).
After this article you should be able to answer three questions:
- I added one dependency and configured nothing — why does every endpoint now demand a login?
- A user is plainly an ADMIN in the database, yet
/api/admin/**returns 403 — which exact string is the framework comparing? - I did write
permitAll()for/actuator/health, but it still returns 401. Where is the rule order wrong?
Add the spring-boot-starter-security dependency, and strange things happen: with zero configuration, every endpoint suddenly requires login; the browser pops up a login dialog and you don't know the credentials; permitting a health-check endpoint seems impossible. Let's answer all three up front, because they point to one mechanism.
Why does everything require login? Because Spring Boot's SecurityAutoConfiguration sees Spring Security classes on the classpath and imports SpringBootWebSecurityConfiguration, registering a default security chain — every request must be authenticated. You didn't misconfigure anything; the framework locked the door first on purpose.
Where is the default password? Search the startup log for Using generated security password. It comes from UserDetailsServiceAutoConfiguration: with no custom user defined, it creates an in-memory user user with a random password. It is for local experiments only — never for production.
How do I permit endpoints? On Spring Boot 3, define a SecurityFilterChain bean using authorizeHttpRequests().requestMatchers(...).permitAll(). Section 6 gives the full config.
spring-boot-starter-security is just Boot's auto-config shell; spring-security-config is the actual core. Confusing the two leads search engines to drag you into "Boot 2 vs Boot 3 syntax" articles — every snippet here targets Boot 3.x.
All three questions trace back to a single dependency. Tick it yourself: Security alone reproduces the first question — nothing configured, everything locked. Then add Web and Actuator and watch the permit rules and the health probe slot in. Every note in the generator tells you what shows up once that option is ticked:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>the pom is where the minimum reproduction starts, not where it ends. The PasswordEncoder of Section 4 and the SecurityFilterChain of Section 6 are what you add on top — the dependency answers whether security exists; configuration answers what it lets through.

Spring Security's real battlefield is not the controller but the Servlet filter chain. After a request enters Tomcat, the first thing it meets is not the DispatcherServlet but a DelegatingFilterProxy named springSecurityFilterChain. That proxy is just a signpost; the real engine is the inner FilterChainProxy.
// The heart of FilterChainProxy (simplified)public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { // 1. Pick the first SecurityFilterChain whose RequestMatcher matches List<FilterChain> chains = getFilters((HttpServletRequest) request); // 2. Run its 15 filters, then hand back to the original servlet chain VirtualFilterChain vfc = new VirtualFilterChain(chain, chains); vfc.doFilter(request, response);}DelegatingFilterProxy: the bridge between the servlet container and the Spring container.FilterChainProxy: the dispatcher that holds severalSecurityFilterChains and picks the first match.VirtualFilterChain: strings the 15 filters into one virtual chain, then returns to the original chain.
The order of these 15 filters is hard-wired and not free to shuffle. Here are the eight most important ones (order = execution order):
| Filter | Job in one sentence |
|---|---|
| SecurityContextHolderFilter | Loads the context from the repository and clears it after the request, preventing thread reuse leaks |
| UsernamePasswordAuthenticationFilter | Handles form login POST /login, turning credentials into an auth request |
| BasicAuthenticationFilter | Handles the HTTP Basic header and hands credentials to the manager |
| ExceptionTranslationFilter | Catches downstream exceptions: auth failure → 401, insufficient authority → 403 |
| AuthorizationFilter | Enforces authorizeHttpRequests rules, throwing AccessDeniedException when it refuses |
| CsrfFilter | Validates the CSRF token; usually disabled for SPA + API |
| LogoutFilter | Handles POST /logout, clearing context and session |
| CorsFilter | Handles cross-origin, including the CORS pre-flight OPTIONS request |
"fifteen" is the count under default configuration and changes as you enable features (OAuth2, Remember-Me). Don't code against the number; remember order and responsibility.
One experiment shows the whole chain and where each filter sits — watch the boundary between the authentication filters and the authorization filter:
How "anonymous" is decided trips up almost everyone: it does not mean "not logged in", it means "this request's Authentication is null":
Both experiments above looked inside the security chain. Now pull the lens back one notch: the security filters are just one platoon in the web filter family — encoding filters, the CORS filter and Spring MVC's interceptors all line up with them. Who runs before whom, and where each one's capabilities stop, decides which layer your next piece of logic belongs to:
Keep the boundary: filters wrap the DispatcherServlet; interceptors run inside it. Anything that must reach a conclusion before a controller exists — security context, encoding, CORS pre-flight — can only be a filter. Once the chain has settled identity, interceptors and controllers can finally argue about what this user may do.
An app can have several SecurityFilterChains. Spring matches them by @Order from smallest upward, and the first chain whose securityMatcher matches wins — later chains never run.
@Bean@Order(1)SecurityFilterChain apiChain(HttpSecurity http) throws Exception { http.securityMatcher("/api/**") .authorizeHttpRequests(a -> a.anyRequest().authenticated()); return http.build();}@Bean@Order(2)SecurityFilterChain webChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(a -> a.anyRequest().permitAll()); return http.build();}- More specific chains get a smaller
@Orderso they are evaluated first. - Cram everything into one chain with a pile of matchers, and a wrong order means "everything requires login".
What is the relationship between those chains, really? Do not imagine it — explode one match into five instants and hold on to the sentence: the first match swallows the request, and no other chain is ever evaluated:

So the answer to "why can /api/admin/orders never reach webChain's permitAll()" is: the @Order(1) chain's securityMatcher("/api/") matched first, and the request belongs to apiChain alone from then on. To open an admin path, add a more specific matcher inside the chain that matched (for instance .requestMatchers("/api/admin/public/").permitAll() in apiChain) — never rely on a second chain as a backstop. That is precisely the point of the second quiz question in Section 12.

Authentication answers one question: prove you are who you claim to be. The chain involves four or five classes and is easy to mix up, so let's walk it in order:
- Submit: the user posts credentials;
UsernamePasswordAuthenticationFiltercatches the request. - Build a token: the filter wraps credentials into an unauthenticated
UsernamePasswordAuthenticationToken(authenticated=false). - Delegate: the token goes to the
AuthenticationManager(defaultProviderManager), which walks itsAuthenticationProviders to find one that supports the token type. - Load the user:
DaoAuthenticationProvidercallsUserDetailsService.loadUserByUsername(), returning aUserDetailswith the encoded password, authorities and account flags. - Compare passwords:
PasswordEncoder.matches(raw, encoded)decides; BCrypt parses the embedded salt and recomputes the hash. - Store the context: on success, build an authenticated
AuthenticationfromUserDetailsand place it in theSecurityContext. - Let it through: the later
AuthorizationFilteruses the authorities to allow or deny.
Where does the SecurityContext live? In SecurityContextHolder, backed by a ThreadLocal. Identity is therefore bound to the current thread: later code in the same request can read it, and SecurityContextHolderFilter clears it at the end so a pooled thread never leaks someone else's identity.
the ThreadLocal binding is both convenient and dangerous — a newly spawned thread cannot see the SecurityContext (say in @Async or a manual new Thread). To pass it across, either copy the context explicitly or use DelegatingSecurityContextExecutor.
Identity living in a ThreadLocal gives the SecurityContext a life of exactly six instants; two of them are routinely overlooked — the clean-up when the request ends, and the emptiness on any other thread:

Two blind spots to memorise: frame ⑤, where a pooled thread is cleared before reuse, is the safety line the framework keeps for you — never take it for granted; frame ⑥ is a design decision rather than a defect — reading identity inside @Async, a pool thread or a scheduled job requires carrying it explicitly (DelegatingSecurityContextExecutor wrapped around the pool), or simply passing the fields you need as arguments.
Step 7, the authorization verdict, deserves its own walk-through: for one and the same request, whether the outcome is 401 or 403 depends entirely on whether the first gate was passed:

Using the animation as reference, nail down the two lines people most often get backwards:
| What you see | What actually happened | Who writes the status code |
|---|---|---|
No credentials at all on /api/admin/** | The rule wants hasRole("ADMIN"), but authentication is null, so the framework decides "identity first" | AuthenticationEntryPoint writes 401 |
A valid token whose role is USER, hitting /api/admin/** | Identity holds, the rule matches, the authority is simply insufficient | AccessDeniedHandler writes 403 |
The form-login path (from UsernamePasswordAuthenticationFilter to SecurityContextHolder) is worth running once more — it is exactly frames 2 through 6 of the animation above, unfolded:
One sentence on BCrypt: add a random salt to every password, then hash with a deliberately slow algorithm. Slow makes brute force expensive; the random salt makes identical passwords hash differently.
Since Spring Security 5 the recommended choice is DelegatingPasswordEncoder, which prefixes the hash with the algorithm id, e.g. {bcrypt}$2a$10$..., so you can run old and new algorithms side by side and migrate gradually:
@Configurationpublic class PasswordConfig { @Bean public PasswordEncoder passwordEncoder() { // Delegates to bcrypt by default; understands {bcrypt}/{argon2}/{pbkdf2} return PasswordEncoderFactories.createDelegatingPasswordEncoder(); }}- On write use
encode()— the result always carries a{bcrypt}prefix and is about 60 chars long. - On check use
matches(); it reads the prefix and picks the algorithm. Never compare hash strings yourself. - Give the password column enough room (
varchar(100)+), or the hash gets truncated.
Authorization answers "are you allowed to do this". Spring Security offers two layers:
@BeanSecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.authorizeHttpRequests(auth -> auth // Order is priority: the more specific rule, the earlier .requestMatchers("/login", "/register", "/actuator/health").permitAll() .requestMatchers("/admin/**").hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/api/posts/**").hasAuthority("post:read") .requestMatchers(HttpMethod.POST, "/api/posts/**").hasAuthority("post:write") .anyRequest().authenticated()); return http.build();}permitAll,authenticated,hasRole/hasAuthoritycover most needs.- Matching is top-down and first-match-wins — put
/**first and everything after it is dead.
The rule table reads simply, but the counter-intuitive part comes after a hit: a non-matching rule just scrolls past, and the first hit decides on the spot — nothing below it is ever read. Step through one GET /api/posts/42: the line pointer walks down the config while the request features and the verdict refresh on the right:
http.authorizeHttpRequests(auth -> auth.requestMatchers("/login", "/register", "/actuator/health").permitAll().requestMatchers("/admin/**").hasRole("ADMIN").requestMatchers(HttpMethod.GET, "/api/posts/**").hasAuthority("post:read").requestMatchers(HttpMethod.POST, "/api/posts/**").hasAuthority("post:write").anyRequest().authenticated());| request | GET /api/posts/42 |
| identity | Bearer token (roles=[USER], authorities=[post:read]) |
AuthorizationFilter.doFilterthe two things people get wrong here are the order (specific first) and the meaning of a hit (a hit is a finished verdict, not an invitation to keep looking for something more permissive). When chasing a 403, first find which line hit, then diff the string it demands against what the database stores.
Method-level authorization needs @EnableMethodSecurity and annotations for finer rules:
@Configuration@EnableMethodSecurity // enables @PreAuthorize / @PostAuthorize / @PreFilterpublic class MethodSecurityConfig { }@Servicepublic class OrderService { @PreAuthorize("hasRole('ADMIN') or #userId == authentication.principal.id") public Order findById(Long userId, Long orderId) { ... } @PostAuthorize("returnObject.ownerId == authentication.principal.id") public Order load(Long orderId) { ... } @PreFilter("filterObject.amount < 10000") public void batchPay(List<Order> orders) { ... }}@PreAuthorize: checks before the method runs; the most common.@PostAuthorize: checks after, ideal for "you may only read your own data".@PreFilter: filters a collection argument, keeping only elements that pass.
Role vs authority — the trap is a single prefix:
| Form | Stored authority | Matching annotation | Note |
|---|---|---|---|
hasRole("ADMIN") | ROLE_ADMIN | hasRole("ADMIN") | The framework adds the ROLE_ prefix |
hasAuthority("ROLE_ADMIN") | ROLE_ADMIN | hasAuthority("ROLE_ADMIN") | You must write the full string |
hasAuthority("post:read") | post:read | hasAuthority("post:read") | Fine-grained authority, no prefix |
the classic "user is ADMIN but gets 403" almost always means the authority string stored in the database lacks the ROLE_ prefix while the code used hasRole("ADMIN"), which expects ROLE_ADMIN. Pick a convention: roles via hasRole, permissions via hasAuthority, never mixed.
This config covers the typical SPA + API needs, explained section by section:
@Configuration@EnableWebSecurity@EnableMethodSecuritypublic class SecurityConfig { private final JwtAuthFilter jwtAuthFilter; // custom JWT filter, see the next article public SecurityConfig(JwtAuthFilter jwtAuthFilter) { this.jwtAuthFilter = jwtAuthFilter; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // 1. Disable CSRF (stateless + SPA, see Section 8) .csrf(AbstractHttpConfigurer::disable) // 2. Disable the default login form and Basic dialog .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) // 3. Stateless sessions: no HttpSession created .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // 4. Permit and authorize rules (order matters) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/api/doc/**", "/actuator/health").permitAll() .requestMatchers("/api/admin/**").hasRole("ADMIN") .anyRequest().authenticated()) // 5. Exception handling: JSON instead of redirecting to a login page .exceptionHandling(ex -> ex .authenticationEntryPoint(restAuthEntryPoint()) // 401 .accessDeniedHandler(restAccessDeniedHandler())) // 403 // 6. CORS .cors(cors -> cors.configurationSource(corsConfigurationSource())) // 7. Insert the JWT filter before the username/password filter .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); }}- Step 4's
authorizeHttpRequestsis order sensitive; permitted endpoints must precede the catch-allanyRequest. - Step 5 replaces the default "redirect to login" with JSON so the frontend can read 401/403.
- Step 7's
addFilterBeforefixes where your filter sits on the chain — a JWT filter must resolve identity before the authorization filter.
Step 7 is also the spot most likely to go wrong in that whole config: you personally thread a filter into the security chain. The stack below comes from a project that copied a JWT-filter tutorial — the filter is in the chain, requests arrive, and then the authentication manager pushes back. Read it first, and pick the guilty frame before peeking at the answer:
A JwtAuthFilter was added following a tutorial: it parses the token from the header, hands it to authenticationManager.authenticate(), and is installed with addFilterBefore. Self-testing shows every request carrying a valid token returns 500 — the controller is never reached, and the token itself was issued seconds ago and is far from expired.
this stack strings together every class name from Section 3 — ProviderManager → your filter → OncePerRequestFilter → VirtualFilterChain → SecurityContextHolderFilter. Read it bottom-up once and the whole "who calls whom" order pays off.
The two status codes are often conflated: 401 = you are not authenticated; 403 = you are authenticated but not allowed. They map to two handlers:
@BeanAuthenticationEntryPoint restAuthEntryPoint() { return (request, response, ex) -> { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":401,\"msg\":\"unauthenticated or session expired\"}"); };}@BeanAccessDeniedHandler restAccessDeniedHandler() { return (request, response, ex) -> { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write("{\"code\":403,\"msg\":\"insufficient authority\"}"); };}AuthenticationEntryPointis invoked byExceptionTranslationFilterwhen it catches anAuthenticationException.AccessDeniedHandlerhandlesAccessDeniedException; for anonymous users it is delegated back to the EntryPoint to become a 401.
Attention: without these handlers, browsers get a 302 redirect to /login, and a frontend fetch reports a confusing cross-origin or HTML error — a common source of "mysterious 401 (actually 302)".
401 and 403 are only the family's two famous members. Real debugging runs you into half a dozen differently named authentication exceptions — all the same flow failing at different checkpoints. Play one round: the left column is the class, the right column is the scene where it fires; a wrong pairing explains itself:
sort the family first and the fix stops going sideways — 'password comparison failed' sends you to the credential source, 'the context is empty' sends you to the thread (Section 3), and 'the authority string falls short' sends you to those characters in the database (Section 5). Three crime scenes, three different checklists.
The defaults from the JSP era (form login, session, CSRF token) mostly don't fit an SPA + REST architecture:
- Why disable sessions? SPA + API usually carries identity in a JWT, so the server keeps no session and
STATELESSmakes each request self-proving. The trade-off: logout and revocation get harder (see the next article). - Why disable CSRF? CSRF exploits the browser auto-sending cookie credentials; a stateless API that doesn't rely on cookies isn't exposed. But the risk is real: the moment you also store tokens in cookies, you must re-enable CSRF.
- CORS pre-flight: a cross-origin
OPTIONSpre-flight carries no credentials and must be permitted before authentication, otherwise the security chain rejects it as a 401 first.
@BeanCorsConfigurationSource corsConfigurationSource() { CorsConfiguration cfg = new CorsConfiguration(); cfg.setAllowedOriginPatterns(List.of("https://app.example.com")); cfg.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); cfg.setAllowedHeaders(List.of("*")); cfg.setAllowCredentials(true); // allow cookies / Authorization UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", cfg); return source;}The CSRF row deserves a hands-on run too: it is on by default, and it fires at a peculiar moment — before the token is parsed:
Once stateless, every request proves itself with a token — and "why should the token be trusted" deserves one hands-on run. Issue, verify, flip a single character, expire: after these four steps you will never forget that the signature is the only genuine-or-forged barrier:
a token's payload is only Base64-encoded, not encrypted — anyone can decode the plaintext inside. So the rules are: keep only low-churn data such as ids and roles in there, inject the secret from environment variables, and always treat a failed signature as unauthenticated.
The security chain answers "who gets in", but there is one attack it cannot stop: brute-forcing passwords with legitimate accounts, or hammering endpoints anonymously at high frequency. That is traffic control, a different concern from authentication — yet the two lines of defense must be stacked:
One line of selection advice: fixed window is the simplest, but requests straddling the window boundary let double the traffic through; the leaky bucket pins outflow to a constant rate and suits protecting a fragile third party; the token bucket allows saving a little credit for bursts and is the general answer for API rate limiting. And don't hand-roll the counter — use Resilience4j or Bucket4j on a single node, move the count into Redis for multiple instances, or your limit is quietly multiplied by the number of replicas.
That last piece is "one layer beyond the security chain". At this point the picture is complete — time to type it out yourself. This console is wired to the same Java kernel as the buttons above, and every echo is computed by the kernel: send the security filters through roll call, then walk the 401/403 fork, the full form-login flow, token issue versus tamper, and two rate limiters side by side:
the order inside the console is the order of the security chain — lab sec deny demonstrates the 401/403 fork, and lab jwt tamper demonstrates the only genuine-or-forged barrier in a stateless design. Type all seven and every conclusion from section 2 to section 8 becomes reproducible on your own.
| Symptom | Root cause | Fix |
|---|---|---|
| Every endpoint requires login | Starter added without a custom SecurityFilterChain | Define permit rules or your own chain |
| 401 but the frontend says "CORS error" | Unauthenticated requests redirect to /login (302), or pre-flight blocked | Add a JSON EntryPoint; permit OPTIONS pre-flight |
| User has authority but 403 | hasRole("ADMIN") expects ROLE_ADMIN; the DB lacks the prefix | Standardize authority strings; add ROLE_ for roles |
| permitAll present but still 401 | Matcher order wrong; an earlier rule matched first | Put the more specific rules earlier |
| Method annotations do nothing | The class isn't proxied (self-invocation, final, non-bean) | Call through the proxy, or use URL-level rules |
| 401 even after a valid login | Stateless mode never writes the context back | Use a token scheme (JWT filter) instead of a session |
Startup fails with The bean 'springSecurityFilterChain' could not be created | Several SecurityFilterChain beans without distinct @Order values, or overlapping securityMatchers creating build-time ambiguity | Give each chain an explicit @Order and a non-overlapping securityMatcher; with a single chain you need no @Order at all |
authorizeHttpRequests is top-down and first-match-wins. Place .anyRequest().authenticated() before .requestMatchers("/login").permitAll() and your login endpoint demands authentication too — order is meaning.
@PreAuthorize relies on an AOP proxy, and so does @Transactional. When both sit on one bean, proxy ordering changes behavior: method security usually runs before the method, while the transaction wraps inside or outside depending on configuration. When chasing oddities like "authority passed but the transaction didn't roll back", remember two proxies are stacked here.
Spring Boot 3 removed WebSecurityConfigurerAdapter entirely. Plenty of old tutorials still teach extends WebSecurityConfigurerAdapter and overriding configure(HttpSecurity) — that won't even compile on Boot 3. The correct approach is to register a SecurityFilterChain bean, as every snippet here does.
The hardest part of debugging a 401 is "but I did write permitAll()". The sandbox below turns where the catch-all rule sits and the request path into two switches, showing the matching process and the final status code side by side — the operable version of the first two rows of Section 9:
Rule 1 matches: .anyRequest().authenticated()Result: 200 for a logged-in user, 401 for an anonymous one# /api/admin/** with hasRole("ADMIN") is never read
the numbers are illustrative; the mechanism is real — authorizeHttpRequests matches top-down and the first hit wins, so the root cause of "the catch-all says authenticated() yet everything returns 401" is always the order, never the permission itself. The second row (catch-all-first plus /login) is the combination beginners hit most: the login endpoint demands authentication too, while the frontend just reports that clicking the button does nothing.
A warm-up on the ROLE_ prefix rule from Section 5:
Then a comprehensive one, tying multi-chain matching from Section 2, 401/403 from Section 7 and the ThreadLocal from Section 3 together:
name the three categories of filters a request passes between entering Tomcat and reaching a controller (authentication / exception translation / authorization), and say who writes 401 and who writes 403.
with different stored authority strings (ADMIN vs ROLE_ADMIN), do hasRole("ADMIN") and hasAuthority("ROLE_ADMIN") match? Why?
why does SecurityContext live in a ThreadLocal? Which mandatory side effect follows, and how do you work around it when reading the identity inside an @Async method?
why does putting .anyRequest().authenticated() before .requestMatchers("/login").permitAll() make the login endpoint return 401 too?
authentication asks "who are you", authorization asks "what may you do"; 401 means never got inside, 403 means inside without permission; rules run top-down and the first hit wins, so the catch-all goes last.
Spring Security looks complex but is really "one filter chain plus two questions". Authentication answers who are you (UsernamePasswordAuthenticationFilter → AuthenticationManager → UserDetailsService → PasswordEncoder → SecurityContext); authorization answers what may you do (AuthorizationFilter + method annotations). Master those two lines, add one correct SecurityFilterChain config, and 401/403 stop being black magic.