Spring Security Internals: Filter Chain, Authentication, Authorization

bee2026-10-0836 min read0 views
Fifteen filters form one security chain where authentication and authorization each do their job — from FilterChainProxy down to SecurityFilterChain config, and why your API suddenly returns 401/403.
1 / 120
Section
0. The 30-second version
2 / 120

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.

3 / 120

Six terms, one line each (used throughout):

4 / 120
  • Authentication: proving "you are who you claim to be"; its output is an Authentication object 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 ThreadLocal and 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
5 / 120
类比|Analogy

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.

6 / 120
Diagram
Figure · Two checkpoints, two different questions
Figure · Two checkpoints, two different questions
7 / 120

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).

8 / 120

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

9 / 120
  • 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?
10 / 120
Section
1. Three questions that drive beginners crazy
11 / 120

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.

12 / 120

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.

13 / 120

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.

14 / 120

How do I permit endpoints? On Spring Boot 3, define a SecurityFilterChain bean using authorizeHttpRequests().requestMatchers(...).permitAll(). Section 6 gives the full config.

15 / 120
Tip

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.

16 / 120

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:

17 / 120
Generator
GeneratorWhat the Security dependency actually pulls inpom.xml2 / 4
Tick Security alone first to see the smallest change — it locks every endpoint at once; then add Web so endpoints can run, Actuator to wire the health probe, and Test for the practice exercises. Each note maps back to one of the three questions above
Output
<?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>
Why each choice matters
parentInheriting 3.3.4 starter-parent means no spring-boot-starter-* needs a version; the moment someone adds an explicit version to one starter, that one wins — the most common source of dependency drift.
WebAnything that serves HTTP needs it: DispatcherServlet, embedded Tomcat and JSON mapping come inside this starter.
SecurityAdding it locks every endpoint behind authentication — the classic “I changed nothing and now everything is 401”.
18 / 120
Note

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.

19 / 120
Section
2. The architecture: one chain, fifteen filters
20 / 120
Diagram
Figure 1 · The security filter chain
Figure 1 · The security filter chain
21 / 120

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.

22 / 120
Code
Codejava
// 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);}
Notes
  • DelegatingFilterProxy: the bridge between the servlet container and the Spring container.
  • FilterChainProxy: the dispatcher that holds several SecurityFilterChains and picks the first match.
  • VirtualFilterChain: strings the 15 filters into one virtual chain, then returns to the original chain.
23 / 120

The order of these 15 filters is hard-wired and not free to shuffle. Here are the eight most important ones (order = execution order):

24 / 120
Table
FilterJob in one sentence
SecurityContextHolderFilterLoads the context from the repository and clears it after the request, preventing thread reuse leaks
UsernamePasswordAuthenticationFilterHandles form login POST /login, turning credentials into an auth request
BasicAuthenticationFilterHandles the HTTP Basic header and hands credentials to the manager
ExceptionTranslationFilterCatches downstream exceptions: auth failure → 401, insufficient authority → 403
AuthorizationFilterEnforces authorizeHttpRequests rules, throwing AccessDeniedException when it refuses
CsrfFilterValidates the CSRF token; usually disabled for SPA + API
LogoutFilterHandles POST /logout, clearing context and session
CorsFilterHandles cross-origin, including the CORS pre-flight OPTIONS request
25 / 120
Note

"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.

26 / 120

One experiment shows the whole chain and where each filter sits — watch the boundary between the authentication filters and the authorization filter:

27 / 120
Kernel lab
TeaVMFilter order: who sits in front of whomidle
Pick the full-order argument: every authentication filter sits ahead of AuthorizationFilter, and that order is why 401 always precedes 403
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
28 / 120

How "anonymous" is decided trips up almost everyone: it does not mean "not logged in", it means "this request's Authentication is null":

29 / 120
Kernel lab
TeaVMHow anonymous access is actually decidedidle
Switch to the anonymous check: the anonymous verdict and an authentication failure are two different things — the former proceeds to authorization, the latter throws AuthenticationException at once
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
30 / 120

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:

31 / 120
Kernel lab
TeaVMWhere the security filters sit in the whole web chainidle
Pick the full order first to see Filter versus Interceptor, then switch to the capability boundary to learn what a filter can do that an interceptor cannot — that boundary is what you consult when choosing a layer
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
32 / 120

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.

33 / 120
Section
2.1 Multiple chains: why some endpoints need no login
34 / 120

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.

35 / 120
Code
Codejava
@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();}
Notes
  • More specific chains get a smaller @Order so they are evaluated first.
  • Cram everything into one chain with a pile of matchers, and a wrong order means "everything requires login".
36 / 120

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:

37 / 120
Diagram
Figure · Several chains, one request: the first match wins
Figure · Several chains, one request: the first match wins
38 / 120

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.

39 / 120
Section
3. The authentication flow: from credentials to SecurityContext
40 / 120
Animation
Animation · The authentication flow
Animation · The authentication flow
41 / 120

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:

42 / 120
  1. Submit: the user posts credentials; UsernamePasswordAuthenticationFilter catches the request.
  2. Build a token: the filter wraps credentials into an unauthenticated UsernamePasswordAuthenticationToken (authenticated=false).
  3. Delegate: the token goes to the AuthenticationManager (default ProviderManager), which walks its AuthenticationProviders to find one that supports the token type.
  4. Load the user: DaoAuthenticationProvider calls UserDetailsService.loadUserByUsername(), returning a UserDetails with the encoded password, authorities and account flags.
  5. Compare passwords: PasswordEncoder.matches(raw, encoded) decides; BCrypt parses the embedded salt and recomputes the hash.
  6. Store the context: on success, build an authenticated Authentication from UserDetails and place it in the SecurityContext.
  7. Let it through: the later AuthorizationFilter uses the authorities to allow or deny.
43 / 120

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.

44 / 120
Trap

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.

45 / 120

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:

46 / 120
Animation
Animation · The life of a SecurityContext
Animation · The life of a SecurityContext
47 / 120

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.

48 / 120

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:

49 / 120
Animation
Animation · Why a request gets 401 rather than 403
Animation · Why a request gets 401 rather than 403
50 / 120

Using the animation as reference, nail down the two lines people most often get backwards:

51 / 120
Table
What you seeWhat actually happenedWho 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 insufficientAccessDeniedHandler writes 403
52 / 120

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:

53 / 120
Kernel lab
TeaVMThe whole form-login flow: from POST to SecurityContextidle
Switch to the form-login flow: watch how the filter wraps the credentials into an unauthenticated token and delegates it to ProviderManager
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
54 / 120
Section
4. Password storage: never plain text
55 / 120

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.

56 / 120

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:

57 / 120
Code
Codejava
@Configurationpublic class PasswordConfig {    @Bean    public PasswordEncoder passwordEncoder() {        // Delegates to bcrypt by default; understands {bcrypt}/{argon2}/{pbkdf2}        return PasswordEncoderFactories.createDelegatingPasswordEncoder();    }}
Notes
  • 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.
58 / 120
Section
5. Authorization: URL level and method level
59 / 120

Authorization answers "are you allowed to do this". Spring Security offers two layers:

60 / 120
Code
Codejava
@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();}
Notes
  • permitAll, authenticated, hasRole / hasAuthority cover most needs.
  • Matching is top-down and first-match-wins — put /** first and everything after it is dead.
61 / 120

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:

62 / 120
Stepper
StepperStep through: how one request is judged by the rule table1 / 7
Click Next seven times. Watch steps 4-7: non-matching rules just scroll past, and the first hit decides on the spot — nothing below it is ever read
Code under debug
1http.authorizeHttpRequests(auth -> auth
2.requestMatchers("/login", "/register", "/actuator/health").permitAll()
3.requestMatchers("/admin/**").hasRole("ADMIN")
4.requestMatchers(HttpMethod.GET, "/api/posts/**").hasAuthority("post:read")
5.requestMatchers(HttpMethod.POST, "/api/posts/**").hasAuthority("post:write")
6.anyRequest().authenticated());
Variables now
requestGET /api/posts/42
identityBearer token (roles=[USER], authorities=[post:read])
Call stack
1AuthorizationFilter.doFilter
1Evaluation starts. Nobody knows the outcome yet — AuthorizationFilter holds only the request features (method, path, identity) and compares them from the first line downward.
63 / 120
Key point

the 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.

64 / 120

Method-level authorization needs @EnableMethodSecurity and annotations for finer rules:

65 / 120
Code
Codejava
@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) { ... }}
Notes
  • @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.
66 / 120

Role vs authority — the trap is a single prefix:

67 / 120
Table
FormStored authorityMatching annotationNote
hasRole("ADMIN")ROLE_ADMINhasRole("ADMIN")The framework adds the ROLE_ prefix
hasAuthority("ROLE_ADMIN")ROLE_ADMINhasAuthority("ROLE_ADMIN")You must write the full string
hasAuthority("post:read")post:readhasAuthority("post:read")Fine-grained authority, no prefix
68 / 120
Trap

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.

69 / 120
Section
6. A production-ready SecurityFilterChain
70 / 120

This config covers the typical SPA + API needs, explained section by section:

71 / 120
Code
Codejava
@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();    }}
Notes
  • Step 4's authorizeHttpRequests is order sensitive; permitted endpoints must precede the catch-all anyRequest.
  • Step 5 replaces the default "redirect to login" with JSON so the frontend can read 401/403.
  • Step 7's addFilterBefore fixes where your filter sits on the chain — a JWT filter must resolve identity before the authorization filter.
72 / 120

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:

73 / 120
Triage
Error triageProviderNotFoundException: No AuthenticationProvider found
A custom JWT filter blows up the moment it enters the chain

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.

org.springframework.security.authentication.ProviderNotFoundException: No AuthenticationProvider found for com.bee.security.JwtAuthenticationToken
at org.springframework.security.authentication.ProviderManager.authenticate(ProviderManager.java:236)
at com.bee.security.JwtAuthFilter.doFilterInternal(JwtAuthFilter.java:63)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:374)
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82)
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:233)
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:191)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:352)
Click the frame you blame — guessing is allowed
No pressure: guess the exception first, then which line actually made the call.
74 / 120
Tip

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.

75 / 120
Section
7. Exception handling: keep 401 and 403 apart
76 / 120

The two status codes are often conflated: 401 = you are not authenticated; 403 = you are authenticated but not allowed. They map to two handlers:

77 / 120
Code
Codejava
@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\"}");    };}
Notes
  • AuthenticationEntryPoint is invoked by ExceptionTranslationFilter when it catches an AuthenticationException.
  • AccessDeniedHandler handles AccessDeniedException; 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)".

78 / 120

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:

79 / 120
Match
MatchThe authentication exception family: name the sceneMatched 0/6 · Missed 0
All six live in Spring Security's standard packages; the right column is each one's crime scene — both columns are shuffled, so do not guess by position
Pick a card on the left first
80 / 120
Key point

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.

81 / 120
Section
8. Fitting stateless front-ends
82 / 120

The defaults from the JSP era (form login, session, CSRF token) mostly don't fit an SPA + REST architecture:

83 / 120
  • Why disable sessions? SPA + API usually carries identity in a JWT, so the server keeps no session and STATELESS makes 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 OPTIONS pre-flight carries no credentials and must be permitted before authentication, otherwise the security chain rejects it as a 401 first.
84 / 120
java
@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;}
85 / 120
Kernel lab
TeaVMDispatch a request through the filter chainidle
Think: do security filters intercept before or after the DispatcherServlet?
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
86 / 120

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:

87 / 120
Kernel lab
TeaVMWhat CSRF actually blocksidle
Switch to the CSRF argument: note that it acts before authentication, so turning it off does not affect login — but the moment tokens move into cookies you must turn it back on
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
88 / 120

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:

89 / 120
Kernel lab
TeaVMWhy a token can be trusted: issue, verify, tamperidle
Walk issue / verify / tamper the payload / expire in turn: watch where verification fails after one flipped character — this is the only genuine-or-forged barrier in a stateless design, and the foundation the dual-token scheme in the next article builds on
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
90 / 120
Note

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.

91 / 120
Section
8.1 One layer beyond authentication: rate limiting
92 / 120

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:

93 / 120
Kernel lab
TeaVMThree rate limiters: fixed window, leaky bucket, token bucketidle
Run the three algorithms in turn: fixed window shows double traffic at the window boundary, leaky bucket shows how constant outflow flattens spikes, token bucket shows how saved tokens absorb a peak — login brute-force protection usually picks the token bucket, counting per IP
Scenario
Click “Run demo” to execute the AOT-compiled Java kernel right in your browser, step by step.
94 / 120

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.

95 / 120

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:

96 / 120
Console
97 / 120
Note

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.

98 / 120
Section
9. 401 / 403 troubleshooting table
99 / 120
Table
SymptomRoot causeFix
Every endpoint requires loginStarter added without a custom SecurityFilterChainDefine permit rules or your own chain
401 but the frontend says "CORS error"Unauthenticated requests redirect to /login (302), or pre-flight blockedAdd a JSON EntryPoint; permit OPTIONS pre-flight
User has authority but 403hasRole("ADMIN") expects ROLE_ADMIN; the DB lacks the prefixStandardize authority strings; add ROLE_ for roles
permitAll present but still 401Matcher order wrong; an earlier rule matched firstPut the more specific rules earlier
Method annotations do nothingThe class isn't proxied (self-invocation, final, non-bean)Call through the proxy, or use URL-level rules
401 even after a valid loginStateless mode never writes the context backUse a token scheme (JWT filter) instead of a session
Startup fails with The bean 'springSecurityFilterChain' could not be createdSeveral SecurityFilterChain beans without distinct @Order values, or overlapping securityMatchers creating build-time ambiguityGive each chain an explicit @Order and a non-overlapping securityMatcher; with a single chain you need no @Order at all
100 / 120
Decision
Decisionan internal admin console, limited users, single server, only your own frontend — do you need OAuth2?
101 / 120
Section
10. Three traps worth memorizing
102 / 120
Trap

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.

103 / 120
Trap

@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.

104 / 120
Trap

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.

105 / 120
Section
11. Sandbox: rule order decides what gets through
106 / 120

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:

107 / 120
Sandbox
SandboxCatch-all position × request path: why permitAll still yields 401
Result
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
A 401 seen while probing anonymously easily convinces you the rules work; in fact the catch-all fired and the authority rule never participated.
108 / 120
Note

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.

109 / 120
Section
12. Check yourself
110 / 120

A warm-up on the ROLE_ prefix rule from Section 5:

111 / 120
Quiz
Check yourselfOne user's permission column stores the string `ADMIN`, while the code uses `requestMatchers("/api/admin/**").hasRole("ADMIN")`. What does this user get on `/api/admin/orders`, and why?
Pick one — you get feedback right away
112 / 120

Then a comprehensive one, tying multi-chain matching from Section 2, 401/403 from Section 7 and the ThreadLocal from Section 3 together:

113 / 120
Quiz
Check yourselfA service registers two chains: an `@Order(1)` `apiChain` with `securityMatcher("/api/**")` and `anyRequest().authenticated()`, and an `@Order(2)` `webChain` with `permitAll()` for everything. Ops complains that "the admin page `/api/admin/orders` asks for a login, but the frontend says we never put a permission on that endpoint". Which statement is correct?
Pick one — you get feedback right away
114 / 120
Section
13. Self-check
115 / 120
Self-check

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.

116 / 120
Self-check

with different stored authority strings (ADMIN vs ROLE_ADMIN), do hasRole("ADMIN") and hasAuthority("ROLE_ADMIN") match? Why?

117 / 120
Self-check

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?

118 / 120
Self-check

why does putting .anyRequest().authenticated() before .requestMatchers("/login").permitAll() make the login endpoint return 401 too?

119 / 120
Mnemonic

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.

120 / 120
Summary

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.