Chapters 3 and 4 built a product catalogue API that anyone can call: DELETE /api/products/1 needs nothing but the URL. Article 15 fixed what a protected API must answer, 401 with a WWW-Authenticate header when it does not know who is calling and 403 when it knows and refuses, and article 20 noted that both come from Spring Security's filters, where an @ExceptionHandler cannot see them. This article opens Chapter 5 by adding Spring Security to the catalogue.
It starts with what the starter alone does to the running application, follows one request through the filter chain that produces those answers, and then replaces Boot's default with a SecurityFilterChain of its own, running into each trap on the way: a POST that fails with valid credentials, a rule order that silently opens an endpoint, a second chain that turns the API's 401 into a redirect to a login page. The examples use Spring Boot 4.1.1, which brings Spring Security 7.1.1, and Java 21, on an Initializr project with the web, validation and security dependencies. The app runs on port 8133 instead of the default 8080, so that is the port in the curl commands.
![]()
The security headers Spring Security adds to every response are shown in full once and left out of later curl -i outputs; log lines keep Boot's default pattern unless an excerpt says it was shortened.
The catalogue API used in this article
The API is Chapter 3's catalogue with an in-memory store, because nothing here needs a database. Generate the project:
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,validation,security" -o demo.zipunzip demo.zip -d demoCompared with the Chapter 3 projects, the generated build.gradle has one more starter and its test counterpart:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-webmvc'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'
testImplementation 'org.springframework.boot:spring-boot-starter-validation-test'
testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}The product classes live in com.example.demo.product:
package com.example.demo.product;
import java.math.BigDecimal;
public record Product(Long id, String sku, String name, BigDecimal price) {
}package com.example.demo.product;
import java.math.BigDecimal;
public record ProductResponse(Long id, String sku, String name, BigDecimal price) {
static ProductResponse from(Product product) {
return new ProductResponse(product.id(), product.sku(), product.name(), product.price());
}
}package com.example.demo.product;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.math.BigDecimal;
public record CreateProductRequest(
@NotBlank String sku,
@NotBlank String name,
@NotNull @Positive BigDecimal price) {
}package com.example.demo.product;
public class ProductNotFoundException extends RuntimeException {
public ProductNotFoundException(long id) {
super("Product " + id + " not found");
}
}package com.example.demo.product;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentSkipListMap;
import java.util.concurrent.atomic.AtomicLong;
import org.springframework.stereotype.Component;
@Component
public class ProductStore {
private final Map<Long, Product> products = new ConcurrentSkipListMap<>();
private final AtomicLong nextId = new AtomicLong(1);
public ProductStore() {
save(new CreateProductRequest("KB-001", "Mechanical keyboard", new BigDecimal("1290000")));
save(new CreateProductRequest("MS-001", "Wireless mouse", new BigDecimal("490000")));
}
public List<Product> findAll() {
return List.copyOf(products.values());
}
public Product findById(long id) {
Product product = products.get(id);
if (product == null) {
throw new ProductNotFoundException(id);
}
return product;
}
public Product save(CreateProductRequest request) {
long id = nextId.getAndIncrement();
Product product = new Product(id, request.sku(), request.name(), request.price());
products.put(id, product);
return product;
}
public Product replace(long id, CreateProductRequest request) {
findById(id);
Product product = new Product(id, request.sku(), request.name(), request.price());
products.put(id, product);
return product;
}
public void delete(long id) {
if (products.remove(id) == null) {
throw new ProductNotFoundException(id);
}
}
}package com.example.demo.product;
import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductStore store;
public ProductController(ProductStore store) {
this.store = store;
}
@GetMapping
public List<ProductResponse> findAll() {
return store.findAll().stream().map(ProductResponse::from).toList();
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable long id) {
return ProductResponse.from(store.findById(id));
}
@PostMapping
public ResponseEntity<ProductResponse> create(@Valid @RequestBody CreateProductRequest request) {
Product product = store.save(request);
URI location = ServletUriComponentsBuilder.fromCurrentRequest()
.path("/{id}").buildAndExpand(product.id()).toUri();
return ResponseEntity.created(location).body(ProductResponse.from(product));
}
@PutMapping("/{id}")
public ProductResponse replace(@PathVariable long id, @Valid @RequestBody CreateProductRequest request) {
return ProductResponse.from(store.replace(id, request));
}
@DeleteMapping("/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void delete(@PathVariable long id) {
store.delete(id);
}
}The advice from article 20, in the common package since article 21, reduced to the one handler this article meets:
package com.example.demo.common;
import com.example.demo.product.ProductNotFoundException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {
@ExceptionHandler(ProductNotFoundException.class)
public ProblemDetail handleNotFound(ProductNotFoundException ex) {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage());
problem.setTitle("Product not found");
return problem;
}
}To see the API before security, the two security lines in build.gradle were commented out for one build:
./gradlew bootJarjava -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8133curl -i http://localhost:8133/api/productsHTTP/1.1 200
Content-Type: application/json
Content-Length: 133
Date: Mon, 14 Sep 2026 04:16:21 GMT
[{"id":1,"sku":"KB-001","name":"Mechanical keyboard","price":1290000},{"id":2,"sku":"MS-001","name":"Wireless mouse","price":490000}]What spring-boot-starter-security changes
In an existing project the change is one starter and its test starter:
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.boot:spring-boot-starter-security-test'<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-test</artifactId>
<scope>test</scope>
</dependency>The Boot 4.1.1 BOM resolves it to spring-security-core, spring-security-web, spring-security-config and spring-security-crypto 7.1.1, plus Boot's spring-boot-security module. With the lines back in, rebuild and start the jar. No code changed, and the startup log has a new warning:
2026-09-14T11:16:23.091+07:00 WARN 51467 --- [demo] [ main] .s.a.UserDetailsServiceAutoConfiguration :
Using generated security password: 8c715543-af93-4f23-be49-d6e09de09d3d
This generated password is for development use only. Your security configuration must be updated before running your application in production.Boot created one user, named user, with a random password that changes on every start. Every endpoint, including the public-looking GET /api/products, now requires it.
A 401 for curl, a redirect to /login for a browser
curl -i http://localhost:8133/api/productsHTTP/1.1 401
Set-Cookie: JSESSIONID=0CDD2FF2F5E062EB813E0B45ECC19E9B; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Length: 0
Date: Mon, 14 Sep 2026 04:16:23 GMTA 401 with a WWW-Authenticate: Basic challenge and an empty body, exactly the status article 15 asked for. The same request as a browser sends it:
curl -i -H 'Accept: text/html' http://localhost:8133/api/productsHTTP/1.1 302
Set-Cookie: JSESSIONID=41C69D81FC08D53C4386A1DFA763AB3C; Path=/; HttpOnly
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Location: http://localhost:8133/login
Content-Length: 0
Date: Mon, 14 Sep 2026 04:16:23 GMTWith logging.level.org.springframework.security=DEBUG, the redirect explains itself:
2026-09-14T11:16:23.803+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.security.web.FilterChainProxy : Securing GET /api/products
2026-09-14T11:16:23.803+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
2026-09-14T11:16:23.804+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.s.w.s.HttpSessionRequestCache : Saved request http://localhost:8133/api/products?continue to session
...
2026-09-14T11:16:23.804+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] s.w.a.DelegatingAuthenticationEntryPoint : Match found! Executing org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint@16da1abc
2026-09-14T11:16:23.805+07:00 DEBUG 51467 --- [demo] [nio-8133-exec-2] o.s.s.web.DefaultRedirectStrategy : Redirecting to /loginBoot's default chain configures both form login and HTTP Basic, and a DelegatingAuthenticationEntryPoint picks one of them from the request's Accept header. For the request with Accept: application/json it logged No match found. Using default entry point org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint@3bcf9488. The Set-Cookie is not a login: HttpSessionRequestCache created a session to remember the request, so it can be replayed after a successful login.
Accept sent | Response | Entry point | Set-Cookie: JSESSIONID |
|---|---|---|---|
*/* (curl's default) | 401, WWW-Authenticate: Basic realm="Realm", charset="UTF-8" | BasicAuthenticationEntryPoint | yes, the request was saved |
text/html | 302, Location: http://localhost:8133/login | LoginUrlAuthenticationEntryPoint | yes |
application/json | 401, same challenge | BasicAuthenticationEntryPoint | no, nothing was saved |
The default login page
/login is served by Spring Security itself. Headless Chrome, driven over the DevTools protocol, opened /api/products, submitted the form once with a wrong password and once with the generated one. The top-level requests it recorded:
1. open http://localhost:8133/api/products
-> GET http://localhost:8133/api/products
<- 302 Location: http://localhost:8133/login
-> GET http://localhost:8133/login
<- 200 text/html
2. submit a wrong password
-> POST http://localhost:8133/login
<- 302 Location: http://localhost:8133/login?error
-> GET http://localhost:8133/login?error
<- 200 text/html
3. submit the right password
-> POST http://localhost:8133/login
<- 302 Location: http://localhost:8133/api/products?continue
-> GET http://localhost:8133/api/products?continue
<- 200 application/jsonThe page is titled "Please sign in" and holds a form posting to /login with a username field, a password field, a hidden _csrf input carrying a 96-character token, and a "Sign in" button, styled by /default-ui.css. After the wrong password it showed "Invalid credentials". After the right one, the saved request came back with ?continue and the browser got the product JSON, with a JSESSIONID cookie (HttpOnly) now holding the login for its next requests.
HTTP Basic with the generated password
An API client sends the credentials with every request instead:
curl -i -u user:8c715543-af93-4f23-be49-d6e09de09d3d http://localhost:8133/api/productsHTTP/1.1 200
X-Content-Type-Options: nosniff
X-XSS-Protection: 0
Cache-Control: no-cache, no-store, max-age=0, must-revalidate
Pragma: no-cache
Expires: 0
X-Frame-Options: DENY
Content-Type: application/json
Content-Length: 133
Date: Mon, 14 Sep 2026 04:16:24 GMT
[{"id":1,"sku":"KB-001","name":"Mechanical keyboard","price":1290000},{"id":2,"sku":"MS-001","name":"Wireless mouse","price":490000}]The body is the one from before the starter; the six headers from X-Content-Type-Options to X-Frame-Options are new on every response and are left out from here on. The successful request created no session. With -u user:wrong the answer was the same 401 challenge as with no credentials at all.
spring.security.user.name, password and roles
A password that changes on every start is no use beyond the first minute. Boot's user can be fixed in configuration:
spring.security.user.name=alice
spring.security.user.password=alice-secret
spring.security.user.roles=USERspring:
security:
user:
name: alice
password: alice-secret
roles: USERWith these properties, the startup log printed no generated password, alice:alice-secret authenticated, and the old user was gone: curl -i -u user:alice-secret http://localhost:8133/api/me answered 401 with the same challenge. /api/me is a small endpoint added in the authentication section below. The rest of the article uses alice until a real user store replaces the properties.
How a request passes through the Spring Security filter chain
Spring Security is not part of Spring MVC. It is a servlet filter that Tomcat runs before DispatcherServlet, and inside it an ordered list of filters of its own.
DelegatingFilterProxy and FilterChainProxy
With logging.level.org.springframework.boot.web.servlet=DEBUG, Boot lists the filters it registers with Tomcat:
2026-09-14T11:16:29.591+07:00 DEBUG 51539 --- [demo] [ main] o.s.b.w.s.ServletContextInitializerBeans : Mapping filters: springSecurityFilterChain urls=[/*] order=-100, characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105
2026-09-14T11:16:29.591+07:00 DEBUG 51539 --- [demo] [ main] o.s.b.w.s.ServletContextInitializerBeans : Mapping servlets: dispatcherServlet urls=[/]
2026-09-14T11:16:29.607+07:00 DEBUG 51539 --- [demo] [ main] .DelegatingFilterProxyRegistrationBean$1 : Filter 'springSecurityFilterChain' configured for usespringSecurityFilterChain is mapped to every URL at order -100, the default of spring.security.filter.order, so it runs after the character encoding, form content and request context filters. What Tomcat holds is a DelegatingFilterProxy: Tomcat only knows servlet filters, and the proxy hands each request to the Spring bean of that name. A temporary new Exception("call path").printStackTrace(System.out) in ProductController.findAll() shows the whole route for an authenticated GET /api/products. Read it from the bottom up:
java.lang.Exception: call path
at com.example.demo.product.ProductController.findAll(ProductController.java:32)
...
at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963)
...
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:132)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:59)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:108)
at org.springframework.security.web.FilterChainProxy.lambda$doFilterInternal$2(FilterChainProxy.java:235)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:376)
at org.springframework.security.web.access.intercept.AuthorizationFilter.doFilter(AuthorizationFilter.java:101)
...
at org.springframework.security.web.access.ExceptionTranslationFilter.doFilter(ExceptionTranslationFilter.java:126)
...
at org.springframework.security.web.authentication.www.BasicAuthenticationFilter.doFilterInternal(BasicAuthenticationFilter.java:247)
...
at org.springframework.security.web.csrf.CsrfFilter.doFilterInternal(CsrfFilter.java:118)
...
at org.springframework.security.web.context.SecurityContextHolderFilter.doFilter(SecurityContextHolderFilter.java:82)
...
at org.springframework.security.web.session.DisableEncodeUrlFilter.doFilterInternal(DisableEncodeUrlFilter.java:42)
at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116)
at org.springframework.security.web.FilterChainProxy$VirtualFilterChain.doFilter(FilterChainProxy.java:385)
at org.springframework.security.web.FilterChainProxy.doFilterInternal(FilterChainProxy.java:237)
at org.springframework.security.web.FilterChainProxy.doFilter(FilterChainProxy.java:195)
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113)
at org.springframework.web.filter.ServletRequestPathFilter.doFilter(ServletRequestPathFilter.java:52)
at org.springframework.web.filter.CompositeFilter$VirtualFilterChain.doFilter(CompositeFilter.java:113)
at org.springframework.web.filter.CompositeFilter.doFilter(CompositeFilter.java:74)
at org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration$CompositeFilterChainProxy.doFilter(WebSecurityConfiguration.java:317)
at org.springframework.web.filter.DelegatingFilterProxy.invokeDelegate(DelegatingFilterProxy.java:355)
at org.springframework.web.filter.DelegatingFilterProxy.doFilter(DelegatingFilterProxy.java:272)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:111)
at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100)
...
at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:199)
...
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:165)- Tomcat's
ApplicationFilterChaincallsCharacterEncodingFilter,RequestContextFilter, thenDelegatingFilterProxy. DelegatingFilterProxycalls thespringSecurityFilterChainbean. In Spring Security 7.1.1 that bean isWebSecurityConfiguration$CompositeFilterChainProxy, which runsServletRequestPathFilterand thenFilterChainProxy.FilterChainProxypicks aSecurityFilterChainand runs its filters through aVirtualFilterChain, each one nested inside the previous:DisableEncodeUrlFilterat the bottom,AuthorizationFilterat the top.- After the last filter,
FilterChainProxy.lambda$doFilterInternal$2hands the request back to Tomcat's chain, which reachesHttpServlet.service,DispatcherServletand the controller.
Every security decision is made before DispatcherServlet is called. That is why article 20's advice never sees them, which a later section shows in practice.
The filters Spring Security prints at startup
FilterChainProxy can hold several SecurityFilterChains. With only the starter, it holds Boot's default one, and the DEBUG level set earlier prints it once at startup:
logging.level.org.springframework.security=DEBUG2026-09-14T11:16:23.137+07:00 DEBUG 51467 --- [demo] [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilterSixteen filters, applied to "any request". Their order is fixed by Spring Security, not by the order in which a configuration mentions them.

One request, filter by filter
At TRACE, FilterChainProxy logs each filter it invokes. The authenticated GET /api/products from the stack trace, with the timestamp, PID and thread cut from each line and some lines skipped (...):
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'defaultSecurityFilterChain' in [class path resource [org/springframework/boot/security/autoconfigure/web/servlet/ServletWebSecurityAutoConfiguration$SecurityFilterChainConfiguration.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization] (1/1)
DEBUG o.s.security.web.FilterChainProxy : Securing GET /api/products
TRACE o.s.security.web.FilterChainProxy : Invoking DisableEncodeUrlFilter (1/16)
TRACE o.s.security.web.FilterChainProxy : Invoking WebAsyncManagerIntegrationFilter (2/16)
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderFilter (3/16)
TRACE o.s.security.web.FilterChainProxy : Invoking HeaderWriterFilter (4/16)
TRACE o.s.security.web.FilterChainProxy : Invoking CsrfFilter (5/16)
TRACE s.s.w.c.CsrfTokenRequestAttributeHandler : Wrote a CSRF token to the following request attributes: [_csrf, org.springframework.security.web.csrf.CsrfToken]
TRACE o.s.security.web.csrf.CsrfFilter : Did not protect against CSRF since request did not match IsNotHttpMethod [TRACE, HEAD, GET, OPTIONS]
TRACE o.s.security.web.FilterChainProxy : Invoking LogoutFilter (6/16)
TRACE o.s.s.w.a.logout.LogoutFilter : Did not match request to PathPattern [POST /logout]
TRACE o.s.security.web.FilterChainProxy : Invoking UsernamePasswordAuthenticationFilter (7/16)
TRACE w.a.UsernamePasswordAuthenticationFilter : Did not match request to PathPattern [POST /login]
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultResourcesFilter (8/16)
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultLoginPageGeneratingFilter (9/16)
TRACE o.s.security.web.FilterChainProxy : Invoking DefaultLogoutPageGeneratingFilter (10/16)
TRACE .w.a.u.DefaultLogoutPageGeneratingFilter : Did not render default logout page since request did not match [PathPattern [GET /logout]]
TRACE o.s.security.web.FilterChainProxy : Invoking BasicAuthenticationFilter (11/16)
TRACE o.s.s.w.a.www.BasicAuthenticationFilter : Found username 'user' in Basic Authorization header
TRACE w.c.HttpSessionSecurityContextRepository : No HttpSession currently exists
TRACE .s.s.w.c.SupplierDeferredSecurityContext : Created SecurityContextImpl [Null authentication]
...
TRACE o.s.s.authentication.ProviderManager : Authenticating request with DaoAuthenticationProvider (1/1)
DEBUG o.s.s.a.dao.DaoAuthenticationProvider : Authenticated user
DEBUG o.s.s.w.a.www.BasicAuthenticationFilter : Set SecurityContextHolder to UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=user, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-09-14T04:16:30.063207Z]]]
TRACE o.s.security.web.FilterChainProxy : Invoking RequestCacheAwareFilter (12/16)
...
TRACE o.s.security.web.FilterChainProxy : Invoking SecurityContextHolderAwareRequestFilter (13/16)
TRACE o.s.security.web.FilterChainProxy : Invoking AnonymousAuthenticationFilter (14/16)
TRACE o.s.security.web.FilterChainProxy : Invoking ExceptionTranslationFilter (15/16)
TRACE o.s.security.web.FilterChainProxy : Invoking AuthorizationFilter (16/16)
TRACE estMatcherDelegatingAuthorizationManager : Authorizing GET /api/products
TRACE estMatcherDelegatingAuthorizationManager : Checking authorization on GET /api/products using org.springframework.security.authorization.AuthenticatedAuthorizationManager@decffa3
...
DEBUG o.s.security.web.FilterChainProxy : Secured GET /api/productsThe filters worth knowing
Most filters in that list did nothing for this request. Six of them decide every outcome in this article:
SecurityContextHolderFilter(3) makes theSecurityContextavailable throughSecurityContextHolderfor the rest of the request. Loading is deferred: the repository was consulted only whenBasicAuthenticationFilterfirst needed the context (No HttpSession currently exists).CsrfFilter(5) leavesGET,HEAD,TRACEandOPTIONSalone and requires a CSRF token on every other method. For this GET it only put a token into request attributes.UsernamePasswordAuthenticationFilter(7) andBasicAuthenticationFilter(11) are the two ways to log in. The first acts only onPOST /loginfrom the form, the second on anAuthorization: Basicheader. On success they put anAuthenticationintoSecurityContextHolder.AnonymousAuthenticationFilter(14) sets anAnonymousAuthenticationTokenforanonymousUserwithROLE_ANONYMOUSwhen nothing authenticated the request, so the filters after it never meet a missingAuthentication.ExceptionTranslationFilter(15) does nothing on the way in. It wraps the filter after it and turns a denial into a 401 or a 403, as the authentication section shows.AuthorizationFilter(16) checks the authorization rules for the request. Boot's default rule is "authenticated"; only if it passes does the request continue toDispatcherServlet.
The rest are supporting cast: HeaderWriterFilter writes the six security headers, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter and DefaultResourcesFilter serve the login page, logout page and /default-ui.css, RequestCacheAwareFilter replays the request saved before a login (?continue), SecurityContextHolderAwareRequestFilter makes request.getUserPrincipal() and isUserInRole() answer from Spring Security, and DisableEncodeUrlFilter keeps session ids out of URLs.
Note the Granted Authorities=[] on the principal and FACTOR_PASSWORD on the token: Boot's generated user has no roles, and Spring Security 7 records which kind of credential authenticated the request. The authentication section comes back to it.
Writing your own SecurityFilterChain
Boot's chain protects every URL the same way and offers a login page no API client will use. The catalogue needs different rules: anyone may read products, only an admin may delete one, every other request needs credentials, and the API uses HTTP Basic without a login form. In Spring Security 7.1.1 that is a SecurityFilterChain bean:
package com.example.demo.common;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.formLogin(form -> form.disable());
return http.build();
}
}HttpSecurityis a builder for one chain, injected by Spring Security. Each method takes a lambda that configures one feature, andhttp.build()produces the chain.authorizeHttpRequestslists rules:requestMatchers(HttpMethod.GET, "/api/products/**")matches the list and every product,permitAll()lets anyone through,hasRole("ADMIN")requires the authorityROLE_ADMIN, andanyRequest().authenticated()covers everything the rules above did not.httpBasic(Customizer.withDefaults())keepsBasicAuthenticationFilter;formLogin(form -> form.disable())says there is no login form.
This lambda style is the only one Spring Security 7.1.1 offers. WebSecurityConfigurerAdapter was removed in Spring Security 6.0, and the 7.1.1 jars have no antMatchers, mvcMatchers, authorizeRequests() or and(). HttpSecurity.build() is declared as public final O build() without throws Exception, so the bean method needs no throws clause either.
The chain after a restart, from the DEBUG line:
2026-09-14T11:21:13.878+07:00 DEBUG 52518 --- [demo] [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilterTwelve filters: the form login filter and the three filters behind the default pages are gone. The rules at work:
curl -i http://localhost:8133/api/products/99HTTP/1.1 404
Content-Type: application/problem+json
Transfer-Encoding: chunked
{"detail":"Product 99 not found","instance":"/api/products/99","status":404,"title":"Product not found"}A public request without credentials reached the controller, and the advice still answered with its ProblemDetail. GET /api/products answered 200 the same way. A protected endpoint without credentials:
curl -i http://localhost:8133/api/meHTTP/1.1 401
Set-Cookie: JSESSIONID=7B9D2ED025F81D54173650EEFFD81FC0; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0With -H 'Accept: text/html' the answer was the same 401 instead of a redirect, and GET /login also answered 401: there is no login page to redirect to.
Is @EnableWebSecurity needed in Spring Boot 4?
No. SecurityConfig above carries only @Configuration, and its chain is the one that ran. Boot supplies the annotation: ServletWebSecurityAutoConfiguration.EnableWebSecurityConfiguration in spring-boot-security 4.1.1 is annotated with @EnableWebSecurity and applies while no springSecurityFilterChain bean exists. The conditions report of a run with --debug:
ServletWebSecurityAutoConfiguration.EnableWebSecurityConfiguration matched:
- @ConditionalOnClass found required class 'org.springframework.security.config.annotation.web.configuration.EnableWebSecurity' (OnClassCondition)
- @ConditionalOnMissingBean (names: springSecurityFilterChain; SearchStrategy: all) did not find any beans (OnBeanCondition)Spring Boot's default chain backs off
The same report explains why the sixteen-filter chain is gone:
ServletWebSecurityAutoConfiguration.SecurityFilterChainConfiguration:
Did not match:
- AllNestedConditions 1 matched 1 did not; NestedCondition on DefaultWebSecurityCondition.Beans @ConditionalOnMissingBean (types: org.springframework.security.web.SecurityFilterChain; SearchStrategy: all) found beans of type 'org.springframework.security.web.SecurityFilterChain' securityFilterChain; NestedCondition on DefaultWebSecurityCondition.Classes @ConditionalOnClass found required classes 'org.springframework.security.web.SecurityFilterChain', 'org.springframework.security.config.annotation.web.builders.HttpSecurity' (DefaultWebSecurityCondition)Boot defines defaultSecurityFilterChain only when there is no bean of type SecurityFilterChain. In the starter-only run the same condition read did not find any beans and the configuration matched. As soon as one chain of yours exists, Boot's is not created: none of its rules survive, and nothing is merged.
Why POST fails with valid credentials: CSRF
With the chain in place, alice creates a product:
curl -i -X POST -u alice:alice-secret -H 'Content-Type: application/json' -d '{"sku":"HB-001","name":"USB-C hub","price":350000}' http://localhost:8133/api/productsHTTP/1.1 401
Set-Cookie: JSESSIONID=0645DFDFC5BB35281D24E97344AC1D7B; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Date: Mon, 14 Sep 2026 04:21:17 GMTA 401 for credentials that worked a second earlier on GET /api/me. DELETE /api/products/2 with the same credentials got the same 401.
The 403 that arrives as a 401
The DEBUG log of that POST:
2026-09-14T11:21:17.523+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.security.web.FilterChainProxy : Securing POST /api/products
2026-09-14T11:21:17.523+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.security.web.csrf.CsrfFilter : Invalid CSRF token found for http://localhost:8133/api/products
2026-09-14T11:21:17.523+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.s.w.access.AccessDeniedHandlerImpl : Responding with 403 status code
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.security.web.FilterChainProxy : Securing GET /error
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] o.s.s.w.s.HttpSessionRequestCache : Saved request http://localhost:8133/error?continue to session
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] s.w.a.DelegatingAuthenticationEntryPoint : Trying to match using RequestHeaderRequestMatcher [expectedHeaderName=X-Requested-With, expectedHeaderValue=XMLHttpRequest]
2026-09-14T11:21:17.524+07:00 DEBUG 52518 --- [demo] [nio-8133-exec-4] s.w.a.DelegatingAuthenticationEntryPoint : No match found. Using default entry point org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint@28a59257CsrfFilterrejected the request with 403. It is filter 5;BasicAuthenticationFilteris filter 7 in this chain, so alice's credentials were never checked.AccessDeniedHandlerImplanswered by callingsendError(403).- Tomcat forwarded the error to
/error, and that ERROR dispatch went through the security chain again. /errorwas anonymous.BasicAuthenticationFilterextendsOncePerRequestFilter, whoseshouldNotFilterErrorDispatch()returnstruein spring-web 7.0.9 and is not overridden, so it does not run on an ERROR dispatch either.anyRequest().authenticated()denied/error, and the Basic entry point wrote a 401 over the 403.
Boot's default chain does exactly the same: the same POST with the generated password, sent to the starter-only application, also came back as a 401. Letting the ERROR dispatch through shows the real status:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/error").permitAll()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()HTTP/1.1 403
Set-Cookie: JSESSIONID=DDC0ED96C8FDFF9FDBC9C9FF5C0F3CB5; Path=/; HttpOnly
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:20 GMT
{"timestamp":"2026-09-14T04:21:20.178Z","status":403,"error":"Forbidden","path":"/api/products"}The log now ends with Secured GET /error, and Boot's error controller wrote its JSON body. This rule was a diagnostic and is removed again; the final configuration writes its 401 and 403 responses without an ERROR dispatch.
CSRF, cross-site request forgery, is an attack in which another website makes a logged-in user's browser send a request that changes something, relying on the browser to attach that user's session cookie by itself. Spring Security's defence is a token the other site cannot read, required on every request except GET, HEAD, TRACE and OPTIONS, and curl sent none.
Disabling CSRF for a stateless API
.httpBasic(Customizer.withDefaults())
.formLogin(form -> form.disable());
.formLogin(form -> form.disable())
.csrf(csrf -> csrf.disable()); ⚠️ Turning CSRF off is right for this API only because nothing in it authenticates a request automatically: every call carries its credentials in the
Authorizationheader, there is no login form, and the next section removes the session cookie as well. A chain that keeps a login in a cookie, such as the form login chain later in this article, must keep CSRF protection on. Article 36 covers when each case applies and how to use the token.
The same POST after a restart:
HTTP/1.1 201
Location: http://localhost:8133/api/products/3
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:26 GMT
{"id":3,"sku":"HB-001","name":"USB-C hub","price":350000}Authentication vs authorization: 401 and 403
Authentication answers who is calling; authorization answers is this caller allowed to do this. Spring Security settles the first before it asks the second, and each failure has its own status. Against the chain above, a POST without credentials:
curl -i -X POST -H 'Content-Type: application/json' -d '{"sku":"HB-001","name":"USB-C hub","price":350000}' http://localhost:8133/api/productsHTTP/1.1 401
Set-Cookie: JSESSIONID=6F6348023468E120EE02EF439F436B96; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Date: Mon, 14 Sep 2026 04:21:26 GMTThe same POST with -u alice:wrong got the same 401, and the log shows where it stopped:
2026-09-14T11:21:27.131+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.a.dao.DaoAuthenticationProvider : Failed to authenticate since password does not match stored value
2026-09-14T11:21:27.133+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.authentication.ProviderManager : Authentication failed with provider DaoAuthenticationProvider since Bad credentials
2026-09-14T11:21:27.133+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.authentication.ProviderManager : Denying authentication since all attempted providers failed
2026-09-14T11:21:27.133+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-4] o.s.s.w.a.www.BasicAuthenticationFilter : Failed to process authentication request
org.springframework.security.authentication.BadCredentialsException: Bad credentialsValid credentials, but a rule alice does not satisfy: she has ROLE_USER and DELETE needs ROLE_ADMIN.
curl -i -X DELETE -u alice:alice-secret http://localhost:8133/api/products/2HTTP/1.1 403
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:27 GMT
{"timestamp":"2026-09-14T04:21:27.646Z","status":403,"error":"Forbidden","path":"/api/products/2"}2026-09-14T11:21:27.571+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.security.web.FilterChainProxy : Securing DELETE /api/products/2
2026-09-14T11:21:27.643+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.s.a.dao.DaoAuthenticationProvider : Authenticated user
...
2026-09-14T11:21:27.644+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.s.w.access.AccessDeniedHandlerImpl : Responding with 403 status code
2026-09-14T11:21:27.645+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.security.web.FilterChainProxy : Securing GET /error
2026-09-14T11:21:27.645+07:00 DEBUG 52717 --- [demo] [nio-8133-exec-6] o.s.security.web.FilterChainProxy : Secured GET /errorThe 403 has a body and the 401 has none, although both went through sendError and an ERROR dispatch. For alice the dispatch to /error was authenticated (Secured GET /error), so Boot's error controller wrote its JSON; for the anonymous request it was denied again and the body stayed empty.

ExceptionTranslationFilter, AuthenticationEntryPoint and AccessDeniedHandler
AuthorizationFilter does not write responses. When a rule fails it throws AuthorizationDeniedException, and ExceptionTranslationFilter, which wraps it, decides what the denial means from the caller in SecurityContextHolder. With logging.level.org.springframework.security.web.access.ExceptionTranslationFilter=TRACE it names the branch. For the anonymous GET /api/products in the starter-only run:
2026-09-14T11:16:30.552+07:00 TRACE 51539 --- [demo] [nio-8133-exec-2] o.s.s.w.a.ExceptionTranslationFilter : Sending AnonymousAuthenticationToken [Principal=anonymousUser, Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_ANONYMOUS]] to authentication entry point since access is denied
org.springframework.security.authorization.AuthorizationDeniedException: Access DeniedFor alice's DELETE in the final configuration at the end of this article, with the prefix cut and the token details shortened to […]:
TRACE o.s.s.w.a.ExceptionTranslationFilter : Sending UsernamePasswordAuthenticationToken […] to access denied handler since access is denied
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied- An anonymous caller goes to the
AuthenticationEntryPoint. Its job is to start authentication:BasicAuthenticationEntryPointanswers 401 with the challenge,LoginUrlAuthenticationEntryPointredirects to the login page. - An authenticated caller goes to the
AccessDeniedHandler. Logging in again would not change the answer, soAccessDeniedHandlerImplanswers 403. - A wrong password never reaches
AuthorizationFilter.BasicAuthenticationFiltercatches theBadCredentialsExceptionitself and calls its own entry point, which is whyFailed to process authentication requestis the last Spring Security line before the 401.
What SecurityContextHolder holds after a login
What a successful login leaves behind is easiest to see from a controller. GET /api/me receives the Authentication and the principal as parameters and compares them with SecurityContextHolder:
package com.example.demo.common;
import java.util.List;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CurrentUserController {
private static final Logger log = LoggerFactory.getLogger(CurrentUserController.class);
public record CurrentUserResponse(String name, List<String> authorities, String authenticationType,
String principalType, boolean credentialsPresent,
boolean sameAsSecurityContextHolder) {
}
@GetMapping("/api/me")
public CurrentUserResponse me(Authentication authentication, @AuthenticationPrincipal UserDetails principal) {
Authentication fromHolder = SecurityContextHolder.getContext().getAuthentication();
log.info("{}", fromHolder);
return new CurrentUserResponse(
authentication.getName(),
authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).toList(),
authentication.getClass().getName(),
principal.getClass().getName(),
authentication.getCredentials() != null,
fromHolder == authentication);
}
}curl -i -u alice:alice-secret http://localhost:8133/api/meHTTP/1.1 200
Content-Type: application/json
Content-Length: 296
Date: Mon, 14 Sep 2026 04:21:29 GMT
{"name":"alice","authorities":["ROLE_USER","FACTOR_PASSWORD"],"authenticationType":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","principalType":"org.springframework.security.core.userdetails.User","credentialsPresent":false,"sameAsSecurityContextHolder":true}2026-09-14T11:21:29.099+07:00 INFO 52717 --- [demo] [nio-8133-exec-2] c.e.demo.common.CurrentUserController : UsernamePasswordAuthenticationToken [Principal=org.springframework.security.core.userdetails.User [Username=alice, Password=[PROTECTED], Enabled=true, AccountNonExpired=true, CredentialsNonExpired=true, AccountNonLocked=true, Granted Authorities=[ROLE_USER]], Credentials=[PROTECTED], Authenticated=true, Details=WebAuthenticationDetails [RemoteIpAddress=0:0:0:0:0:0:0:1, SessionId=null], Granted Authorities=[ROLE_USER, FactorGrantedAuthority [authority=FACTOR_PASSWORD, issuedAt=2026-09-14T04:21:29.097929Z]]]SecurityContextHolderholds aSecurityContextwhoseAuthenticationis aUsernamePasswordAuthenticationToken. The controller parameter is the same object (sameAsSecurityContextHolder: true).- The principal is a
UserDetails, here Spring Security'sUser, with the roles it was created with:ROLE_USER, fromspring.security.user.roles=USER. - The credentials are gone.
getCredentials()returnednullafter authentication, so the password does not travel further into the application. - The token's authorities add
FACTOR_PASSWORD. Spring Security 7 adds aFactorGrantedAuthoritynaming the kind of credential that authenticated the request, with the time it was issued.spring-security-config7.1.1 also contains@EnableMultiFactorAuthentication; multi-factor authentication is a topic for the Advanced course. Detailsrecords the remote address and the session id,nullhere because HTTP Basic created no session.
A stateless API with SessionCreationPolicy.STATELESS
Several 401s above set a JSESSIONID cookie on a client that never logs in through a session. One setting stops it:
import org.springframework.security.config.http.SessionCreationPolicy;
// ...
.formLogin(form -> form.disable())
.csrf(csrf -> csrf.disable());
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); The POST without credentials, before and after:
HTTP/1.1 401
Set-Cookie: JSESSIONID=6F6348023468E120EE02EF439F436B96; Path=/; HttpOnly
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0HTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0The log line Saved request http://localhost:8133/api/products?continue to session is gone with the cookie: a stateless chain does not save requests for a login page it does not have. SessionManagementFilter joined the startup filter list. What did not change: a successful Basic request created no session in either configuration. curl -c wrote an empty cookie jar after GET /api/me as alice in both, and without STATELESS a follow-up request with that jar and no credentials got a plain 401. HTTP Basic sends credentials on every request, so there is no session to keep, and STATELESS makes sure none appears by accident.
requestMatchers order: the first match wins
AuthorizationFilter checks the rules from top to bottom and uses the first one whose matcher fits the request. It does not look for the most specific rule. The next three runs change only the rules of the stateless chain.
A rule order that closes an endpoint
A rule for the whole API added above the public one:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.anyRequest().authenticated())curl -i http://localhost:8133/api/productsHTTP/1.1 401
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Content-Length: 0
Date: Mon, 14 Sep 2026 04:35:52 GMTGET /api/products matched /api/** first, so permitAll() below it never applied, and the product list now requires credentials (-u alice:alice-secret got 200). The application started without a single WARN line.
A rule order that opens an endpoint
The opposite mistake is worse. The public rule written without its method, above the admin rule:
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers("/api/products/**").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.anyRequest().authenticated())curl -i -X DELETE http://localhost:8133/api/products/2HTTP/1.1 204
Date: Mon, 14 Sep 2026 04:21:38 GMT2026-09-14T11:21:38.090+07:00 DEBUG 52881 --- [demo] [nio-8133-exec-1] o.s.security.web.FilterChainProxy : Securing DELETE /api/products/2
2026-09-14T11:21:38.092+07:00 DEBUG 52881 --- [demo] [nio-8133-exec-1] o.s.s.w.a.AnonymousAuthenticationFilter : Set SecurityContextHolder to anonymous SecurityContext
2026-09-14T11:21:38.093+07:00 DEBUG 52881 --- [demo] [nio-8133-exec-1] o.s.security.web.FilterChainProxy : Secured DELETE /api/products/2An anonymous caller deleted product 2; the next GET /api/products listed only the keyboard. hasRole("ADMIN") is still in the configuration and never runs. Put narrow rules above broad ones, keep the HTTP method in rules that are meant for one method, and end with anyRequest(). That last position is the only one the configurer enforces: AbstractRequestMatcherRegistry in the 7.1.1 jar carries the message Can't configure requestMatchers after anyRequest.
permitAll() vs anonymous()
The public rule with anonymous() instead of permitAll():
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").anonymous()
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.anyRequest().authenticated())Without credentials GET /api/products answered 200. With alice's:
curl -i -u alice:alice-secret http://localhost:8133/api/productsHTTP/1.1 403
Content-Type: application/json
Transfer-Encoding: chunked
Date: Mon, 14 Sep 2026 04:21:41 GMT
{"timestamp":"2026-09-14T04:21:41.338Z","status":403,"error":"Forbidden","path":"/api/products"}permitAll() allows every caller. anonymous() allows only a request that AnonymousAuthenticationFilter marked as anonymousUser, so a logged-in user is refused; it fits a registration page, not a public catalogue. Neither rule ignores credentials that are sent: with permitAll(), curl -i -u alice:wrong http://localhost:8133/api/products answered 401, because BasicAuthenticationFilter rejects a bad Authorization header before any rule is checked.
Two SecurityFilterChains with securityMatcher and @Order
Most applications also serve something other than the API: an admin page, a server-rendered site like article 24's. Those want a login form, a session and CSRF protection, which is everything the API chain turned off. Instead of one chain that compromises, define two:
package com.example.demo.common;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
@Order(1)
SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain webSecurityFilterChain(HttpSecurity http) {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
}package com.example.demo.common;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class HomeController {
@GetMapping("/")
public String home() {
return "Catalogue home";
}
}securityMatcher("/api/**")decides which requests a chain handles at all.requestMatchersinsideauthorizeHttpRequestsonly decide rules within the chain.@Orderdecides the order in whichFilterChainProxyasks the chains. The web chain has nosecurityMatcher, so it matches every request.
The startup log prints both chains:
2026-09-14T11:29:06.004+07:00 DEBUG 56717 --- [demo] [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure Or [PathPattern [/api/**]] with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, LogoutFilter, BasicAuthenticationFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, SessionManagementFilter, ExceptionTranslationFilter, AuthorizationFilter
2026-09-14T11:29:06.018+07:00 DEBUG 56717 --- [demo] [ main] o.s.s.web.DefaultSecurityFilterChain : Will secure any request with filters: DisableEncodeUrlFilter, WebAsyncManagerIntegrationFilter, SecurityContextHolderFilter, HeaderWriterFilter, CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter, DefaultResourcesFilter, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter, RequestCacheAwareFilter, SecurityContextHolderAwareRequestFilter, AnonymousAuthenticationFilter, ExceptionTranslationFilter, AuthorizationFilterThe API chain has no CsrfFilter and no UsernamePasswordAuthenticationFilter, although it never calls formLogin(form -> form.disable()): a chain built from HttpSecurity has form login only if it calls formLogin(). The line in the single-chain version documented a decision rather than changing the filters. The web chain has the login filters and CsrfFilter, and no BasicAuthenticationFilter.
Which chain handled the request
With logging.level.org.springframework.security.web.FilterChainProxy=TRACE, each request logs the chains it was matched against. The prefixes are cut and the filter lists replaced by […]:
### GET /api/products -> 200
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
DEBUG o.s.security.web.FilterChainProxy : Securing GET /api/products
DEBUG o.s.security.web.FilterChainProxy : Secured GET /api/products
### GET / -> 302 Location: http://localhost:8133/login
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'webSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [any request] and having filters […] (2/2)
DEBUG o.s.security.web.FilterChainProxy : Securing GET /
DEBUG o.s.s.web.DefaultRedirectStrategy : Redirecting to /login/api/products matched the first chain and the second was never asked. / did not match /api/**, so FilterChainProxy moved to the second chain, which matches anything, and its form login entry point redirected to /login, even with -u alice:alice-secret, because this chain does not read Basic credentials.
![How FilterChainProxy picks a chain: GET /api/products is tried against apiSecurityFilterChain with @Order(1) and matcher Or [PathPattern [/api/**]], matches, and gets HTTP/1.1 200 without webSecurityFilterChain being asked; GET / does not match the first chain, matches webSecurityFilterChain with @Order(2) for any request, and gets HTTP/1.1 302 to http://localhost:8133/login; with the @Order values swapped, webSecurityFilterChain matches every request first, apiSecurityFilterChain would never be invoked, and the application fails at startup with UnreachableFilterChainException](/images/blog/sb-securityfilterchain-matching.en.webp)
A 401 that turns into a redirect to /login
The API chain has not changed, yet an unauthenticated API call no longer gets its 401:
curl -i http://localhost:8133/api/meHTTP/1.1 302
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Set-Cookie: JSESSIONID=AE729E73CC623615A92322DA476669D5; Path=/; HttpOnly
Location: http://localhost:8133/login;jsessionid=AE729E73CC623615A92322DA476669D5
Content-Length: 0
Date: Mon, 14 Sep 2026 04:29:06 GMTTRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
DEBUG o.s.security.web.FilterChainProxy : Securing GET /api/me
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters […] (1/2)
TRACE o.s.security.web.FilterChainProxy : Trying to match request against DefaultSecurityFilterChain defined as 'webSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [any request] and having filters […] (2/2)
DEBUG o.s.security.web.FilterChainProxy : Securing GET /error
DEBUG o.s.s.web.DefaultRedirectStrategy : Redirecting to /login;jsessionid=AE729E73CC623615A92322DA476669D5The API chain did answer 401: its WWW-Authenticate header is still in the response. But the Basic entry point uses sendError, the ERROR dispatch went to /error, and /error is not under /api/**. The web chain took it, found an anonymous caller, and its form login entry point replaced the 401 with a redirect. The session id in Location comes from the same dispatch: DisableEncodeUrlFilter, which keeps session ids out of URLs, is also a OncePerRequestFilter that does not override shouldNotFilterErrorDispatch(), so it did not run for /error either. The same happened with -H 'Accept: text/html' and with a wrong password. An authenticated denial still worked: alice's DELETE /api/products/2 answered 403 with Boot's JSON body, because the ERROR dispatch for her was authenticated. The next section removes the ERROR dispatch from the API's answers altogether.
Reversing the @Order values
With @Order(1) on the web chain and @Order(2) on the API chain, the application did not start:
2026-09-14T11:29:10.892+07:00 ERROR 58644 --- [demo] [ main] o.s.boot.SpringApplication : Application run failed
...
Caused by: org.springframework.security.web.UnreachableFilterChainException: A filter chain that matches any request [DefaultSecurityFilterChain defined as 'webSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [any request] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Csrf, Logout, UsernamePasswordAuthentication, DefaultResources, DefaultLoginPageGenerating, DefaultLogoutPageGenerating, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, ExceptionTranslation, Authorization]] has already been configured, which means that this filter chain [DefaultSecurityFilterChain defined as 'apiSecurityFilterChain' in [class path resource [com/example/demo/common/SecurityConfig.class]] matching [Or [PathPattern [/api/**]]] and having filters [DisableEncodeUrl, WebAsyncManagerIntegration, SecurityContextHolder, HeaderWriter, Logout, BasicAuthentication, RequestCacheAware, SecurityContextHolderAwareRequest, AnonymousAuthentication, SessionManagement, ExceptionTranslation, Authorization]] 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.Unlike rules inside one chain, chain order has a safety net for this case: a chain that matches any request ahead of another chain is rejected at startup. The message is about "any request"; for chains with narrower matchers that overlap, such as /api/** and /api/products/**, give the narrower one the lower @Order value.
Returning 401 and 403 as ProblemDetail
The API now answers a 401 with an empty body or a redirect, and a 403 with Boot's default JSON. Article 20 gave every other error an RFC 9457 ProblemDetail with Content-Type: application/problem+json.
Why @RestControllerAdvice never sees them
The obvious attempt is two more handlers in the advice:
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(AuthenticationException.class)
public ProblemDetail handleAuthentication(AuthenticationException ex) {
log.warn("handleAuthentication called: {}", ex.toString());
return ProblemDetail.forStatus(HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler(AccessDeniedException.class)
public ProblemDetail handleAccessDenied(AccessDeniedException ex) {
log.warn("handleAccessDenied called: {}", ex.toString());
return ProblemDetail.forStatus(HttpStatus.FORBIDDEN);
} The two-chain runs in the previous section were made with these handlers already in the class: the 302 for /api/me, the 302 for the wrong password and the 403 with Boot's JSON were their results, and neither handleAuthentication called nor handleAccessDenied called appeared in the log. AuthenticationException and AuthorizationDeniedException are thrown and handled inside the security filters, before DispatcherServlet is called, and @ExceptionHandler methods only see exceptions from handler methods that DispatcherServlet called. Remove the two handlers; the place to shape these responses is the entry point and the access denied handler.
An entry point and an access denied handler that write ProblemDetail
One component implements both interfaces and writes the response itself, with the JsonMapper Boot configures for Jackson 3:
package com.example.demo.common;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URI;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import tools.jackson.databind.json.JsonMapper;
@Component
public class ProblemDetailSecurityHandler implements AuthenticationEntryPoint, AccessDeniedHandler {
private final JsonMapper jsonMapper;
public ProblemDetailSecurityHandler(JsonMapper jsonMapper) {
this.jsonMapper = jsonMapper;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException ex) throws IOException {
response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Basic realm=\"catalogue\"");
write(request, response, HttpStatus.UNAUTHORIZED, "Valid credentials are required to access this resource.");
}
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException ex) throws IOException {
write(request, response, HttpStatus.FORBIDDEN, "You are not allowed to perform this operation.");
}
private void write(HttpServletRequest request, HttpServletResponse response,
HttpStatus status, String detail) throws IOException {
ProblemDetail problem = ProblemDetail.forStatusAndDetail(status, detail);
problem.setInstance(URI.create(request.getRequestURI()));
response.setStatus(status.value());
response.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
jsonMapper.writeValue(response.getOutputStream(), problem);
}
}commenceis theAuthenticationEntryPointmethod. It setsWWW-Authenticateitself, because a 401 without it tells the client nothing about how to authenticate.handleis theAccessDeniedHandlermethod.- Neither calls
sendError. The response is written and committed in place, so there is no ERROR dispatch for another chain to take over. - The
detailis fixed. The exception message would tell a caller whether the user name exists or the password was wrong.
AccessDeniedException is the parameter type of the interface; AuthorizationDeniedException extends it. Wire the component into the API chain through exceptionHandling:
@Bean
@Order(1)
SecurityFilterChain apiSecurityFilterChain(HttpSecurity http) {
SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(problemHandler)
.accessDeniedHandler(problemHandler))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}curl -i http://localhost:8133/api/meHTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
Content-Type: application/problem+json
Content-Length: 125
Date: Mon, 14 Sep 2026 04:29:12 GMT
{"detail":"Valid credentials are required to access this resource.","instance":"/api/me","status":401,"title":"Unauthorized"}curl -i -X DELETE -u alice:alice-secret http://localhost:8133/api/products/2HTTP/1.1 403
Content-Type: application/problem+json
Content-Length: 121
Date: Mon, 14 Sep 2026 04:29:14 GMT
{"detail":"You are not allowed to perform this operation.","instance":"/api/products/2","status":403,"title":"Forbidden"}Both bodies have the shape of article 20's handlers: the standard members in alphabetical order, the request path as instance, the reason phrase as title. A wrong password did not:
curl -i -u alice:wrong http://localhost:8133/api/meHTTP/1.1 302
WWW-Authenticate: Basic realm="Realm", charset="UTF-8"
Set-Cookie: JSESSIONID=48EEB4984DDE4081E158FE00F5812246; Path=/; HttpOnly
Location: http://localhost:8133/login;jsessionid=48EEB4984DDE4081E158FE00F5812246
Content-Length: 0
Date: Mon, 14 Sep 2026 04:29:13 GMTThe entry point BasicAuthenticationFilter uses
realm="Realm" gives it away: that 401 came from the default BasicAuthenticationEntryPoint, not from the new component, and its sendError handed the request to the web chain again. exceptionHandling configures ExceptionTranslationFilter, and a wrong password never gets that far; BasicAuthenticationFilter calls the entry point of httpBasic itself. It needs the same component:
.httpBasic(Customizer.withDefaults())
.httpBasic(basic -> basic.authenticationEntryPoint(problemHandler)) curl -i -u alice:wrong http://localhost:8133/api/meHTTP/1.1 401
WWW-Authenticate: Basic realm="catalogue"
Content-Type: application/problem+json
Content-Length: 125
Date: Mon, 14 Sep 2026 04:29:16 GMT
{"detail":"Valid credentials are required to access this resource.","instance":"/api/me","status":401,"title":"Unauthorized"}In the same run, curl -i -u alice:wrong http://localhost:8133/api/products got the same 401 body with "instance":"/api/products", -H 'Accept: text/html' on /api/me got the same 401 instead of a redirect, and alice's DELETE still got the 403 above. Every security answer of the API is now a ProblemDetail, and the 401 keeps its WWW-Authenticate header.
In-memory users with InMemoryUserDetailsManager
spring.security.user.* configures one user. Two users with different roles need a UserDetailsService, and for now the in-memory one is enough. The finished SecurityConfig:
package com.example.demo.common;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class SecurityConfig {
@Bean
@Order(1)
SecurityFilterChain apiSecurityFilterChain(HttpSecurity http, ProblemDetailSecurityHandler problemHandler) {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").permitAll()
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.anyRequest().authenticated())
.httpBasic(basic -> basic.authenticationEntryPoint(problemHandler))
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint(problemHandler)
.accessDeniedHandler(problemHandler))
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain webSecurityFilterChain(HttpSecurity http) {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
@Bean
InMemoryUserDetailsManager userDetailsService() {
UserDetails alice = User.withUsername("alice")
.password("{noop}alice-secret")
.roles("USER")
.build();
UserDetails bob = User.withUsername("bob")
.password("{noop}bob-secret")
.roles("USER", "ADMIN")
.build();
return new InMemoryUserDetailsManager(alice, bob);
}
}The spring.security.user.* lines come out of application.properties:
spring.application.name=demo
spring.security.user.name=alice
spring.security.user.password=alice-secret
spring.security.user.roles=USER roles("ADMIN") stores the authority ROLE_ADMIN, which is what hasRole("ADMIN") checks. The two users against the admin rule:
curl -i -X DELETE -u alice:alice-secret http://localhost:8133/api/products/2HTTP/1.1 403
Content-Type: application/problem+json
Content-Length: 121
Date: Mon, 14 Sep 2026 04:29:20 GMT
{"detail":"You are not allowed to perform this operation.","instance":"/api/products/2","status":403,"title":"Forbidden"}curl -i -X DELETE -u bob:bob-secret http://localhost:8133/api/products/2HTTP/1.1 204
Date: Mon, 14 Sep 2026 04:29:20 GMTcurl -i -u bob:bob-secret http://localhost:8133/api/meHTTP/1.1 200
Content-Type: application/json
Content-Length: 307
Date: Mon, 14 Sep 2026 04:29:21 GMT
{"name":"bob","authorities":["ROLE_ADMIN","ROLE_USER","FACTOR_PASSWORD"],"authenticationType":"org.springframework.security.authentication.UsernamePasswordAuthenticationToken","principalType":"org.springframework.security.core.userdetails.User","credentialsPresent":false,"sameAsSecurityContextHolder":true}What happens to the generated password
It is gone, and not because the properties were removed: the startup log of this run had no Using generated security password line at all, and the conditions report, with --debug, says why:
UserDetailsServiceAutoConfiguration:
Did not match:
- @ConditionalOnMissingBean (types: org.springframework.security.authentication.AuthenticationManager,org.springframework.security.authentication.AuthenticationProvider,org.springframework.security.core.userdetails.UserDetailsService,org.springframework.security.authentication.AuthenticationManagerResolver,org.springframework.security.oauth2.jwt.JwtDecoder; SearchStrategy: all) found beans of type 'org.springframework.security.core.userdetails.UserDetailsService' userDetailsService (OnBeanCondition)Boot creates its user, generated or from spring.security.user.*, only while the application defines no UserDetailsService, AuthenticationProvider, AuthenticationManager, AuthenticationManagerResolver or JwtDecoder. With the bean above, the properties would be ignored even if they stayed.
Password prefixes and withDefaultPasswordEncoder()
Passwords stored for Spring Security's default DelegatingPasswordEncoder start with the id of the encoder that produced them: {noop} means the rest is plain text compared as is, {bcrypt} means the rest is a BCrypt hash. Boot relies on the same scheme for its own user: in UserDetailsServiceAutoConfiguration 4.1.1, a property password without an {id} prefix gets the constant NOOP_PASSWORD_PREFIX = "{noop}" put in front of it when the application has no PasswordEncoder bean. A startup probe run in the same application printed what each form does:
2026-09-14T11:29:20.283+07:00 INFO 59512 --- [demo] [ main] com.example.demo.common.PasswordProbe : withDefaultPasswordEncoder stores: {bcrypt}$2a$10$Kf8hsJdaA/IZKGvT28CM2u9QF1NZd2OOG4DJZQ5eS/qtJRq72dXr2
2026-09-14T11:29:20.283+07:00 INFO 59512 --- [demo] [ main] com.example.demo.common.PasswordProbe : matches("bob-secret", "{noop}bob-secret") = true
2026-09-14T11:29:20.283+07:00 INFO 59512 --- [demo] [ main] com.example.demo.common.PasswordProbe : matches without a prefix threw java.lang.IllegalArgumentException: Given that there is no default password encoder configured, each password must have a password encoding prefix. Please either prefix this password with '{noop}' or set a default password encoder in `DelegatingPasswordEncoder`.User.withDefaultPasswordEncoder() hashes the password with BCrypt while the user is built, but it is marked deprecated in spring-security-core 7.1.1 (javap -v shows Deprecated: true), and its bytecode carries the warning "User.withDefaultPasswordEncoder() is considered unsafe for production and is only intended for sample applications": the plain password is still in the source code. {noop} is honest about the same thing and is fine for a demo. Article 34 moves the users into the database with BCrypt hashes.
Spring Security components at a glance
Every row is something this article ran into.
| Component | Responsibility | Where you touch it |
|---|---|---|
DelegatingFilterProxy | the servlet filter Tomcat runs as springSecurityFilterChain, order -100; hands each request to the Spring bean | spring.security.filter.order; normally never |
FilterChainProxy | runs the first SecurityFilterChain whose matcher matches the request | logging.level.org.springframework.security.web.FilterChainProxy=TRACE |
SecurityFilterChain | an ordered list of filters for the requests its securityMatcher selects | a @Bean built from HttpSecurity, with @Order and securityMatcher |
SecurityContextHolderFilter | makes the SecurityContext available for the request | sessionManagement(...) decides whether a session is used |
CsrfFilter | rejects requests other than GET, HEAD, TRACE and OPTIONS without a CSRF token | csrf(...) |
BasicAuthenticationFilter | authenticates the Authorization: Basic header; on failure calls its own entry point | httpBasic(...) |
UsernamePasswordAuthenticationFilter | authenticates POST /login from the login form | formLogin(...) |
AnonymousAuthenticationFilter | sets anonymousUser with ROLE_ANONYMOUS when nobody authenticated | anonymous() rules match it |
ExceptionTranslationFilter | sends a denied anonymous caller to the entry point, a denied authenticated caller to the access denied handler | exceptionHandling(...) |
AuthorizationFilter | checks the authorizeHttpRequests rules, first match wins | requestMatchers, permitAll(), anonymous(), hasRole(), authenticated(), anyRequest() |
AuthenticationEntryPoint | answers "who are you?" with a 401 challenge or a redirect to the login page | exceptionHandling(...) and httpBasic(...) |
AccessDeniedHandler | answers "not allowed" with a 403 | exceptionHandling(...) |
SecurityContextHolder, Authentication | the current caller: principal, authorities, details | Authentication and @AuthenticationPrincipal controller parameters |
UserDetailsService | loads a user by name; its presence switches off Boot's user | InMemoryUserDetailsManager bean here, the database in article 34 |
DelegatingPasswordEncoder | compares passwords using the {id} prefix | {noop} here, BCrypt in article 34 |
FAQ
Why does Spring Security return 401 instead of showing the login page?
Because the request did not ask for HTML. Boot's default chain has form login and HTTP Basic, and its entry point chooses by the Accept header: curl with Accept: */* or application/json got 401 with WWW-Authenticate: Basic realm="Realm", charset="UTF-8", while Accept: text/html got 302 to /login. A chain of your own with only httpBasic() answers 401 to both.
Is WebSecurityConfigurerAdapter available in Spring Security 7?
No. It was removed in Spring Security 6.0, and the spring-security-config 7.1.1 jar has no class of that name, no antMatchers or mvcMatchers, no authorizeRequests() and no and(). Security is configured by SecurityFilterChain beans built from HttpSecurity with lambdas, and requestMatchers replaces the old matcher methods.
Why does a POST return 403 or 401 with valid credentials?
CSRF protection is on by default and rejects every POST, PUT, PATCH and DELETE without a CSRF token, before the credentials are even checked. CsrfFilter answers 403, but with HTTP Basic the ERROR dispatch to /error runs anonymously, so a chain that does not permit /error turns it into a 401. The DEBUG line Invalid CSRF token found identifies the cause. For a stateless API that sends credentials in a header, disable CSRF; for anything that logs in with a cookie, send the token instead.
Can @RestControllerAdvice handle AccessDeniedException from Spring Security?
Not for rules checked by the security filters. The denial is thrown and handled inside the filter chain before DispatcherServlet runs; handlers for AuthenticationException and AccessDeniedException in the advice were never called in this article's runs. Shape these responses with an AuthenticationEntryPoint and an AccessDeniedHandler, and set the entry point on httpBasic(...) as well as on exceptionHandling(...).
What is the difference between permitAll() and anonymous()?
permitAll() lets every caller through. anonymous() lets through only a caller that AnonymousAuthenticationFilter marked as anonymousUser: GET /api/products with anonymous() answered 200 without credentials and 403 with alice's valid credentials. With either rule, a request carrying a wrong password is rejected with 401 by BasicAuthenticationFilter before the rule is checked.
What is FACTOR_PASSWORD in the authorities?
A FactorGrantedAuthority that Spring Security 7 adds to the Authentication to record that the caller authenticated with a password, together with the time it was issued. alice's /api/me response listed ["ROLE_USER","FACTOR_PASSWORD"] although she was created with only ROLE_USER. It does not change hasRole rules: alice still got 403 and bob 204 on DELETE with it in their authorities. Multi-factor authentication, @EnableMultiFactorAuthentication in 7.1.1, is a topic for the Advanced course.
Why does my second SecurityFilterChain never run?
Because an earlier chain matches its requests. FilterChainProxy uses only the first chain whose securityMatcher matches, in @Order. If the earlier chain has no securityMatcher, it matches any request, and Spring Security 7.1.1 refuses to start with UnreachableFilterChainException. Give the narrower chain the lower @Order value and leave the chain without securityMatcher last.
Conclusion
Spring Security is a servlet filter in front of Spring MVC: Tomcat calls DelegatingFilterProxy, which calls FilterChainProxy, which runs the first SecurityFilterChain that matches the request and its ordered filters before DispatcherServlet sees anything. The starter alone locks every endpoint behind a generated user, answering 401 to an API client and redirecting a browser to a login page. A SecurityFilterChain bean of your own replaces Boot's chain entirely, needs no @EnableWebSecurity, and in Spring Security 7.1.1 is written only with the lambda DSL. Authentication failures reach the AuthenticationEntryPoint as 401, authorization failures reach the AccessDeniedHandler as 403, and ExceptionTranslationFilter chooses between them from the caller in SecurityContextHolder.
The traps are about order and dispatches. Rules and chains are both first match wins, and a rule order can silently open a DELETE to anyone. CSRF rejects a POST before its credentials are checked, and the ERROR dispatch can turn that 403 into a 401, or a second chain can turn an API's 401 into a redirect. An entry point and an access denied handler that write ProblemDetail themselves, set on both httpBasic and exceptionHandling, give the API consistent 401 and 403 answers with WWW-Authenticate intact. STATELESS keeps sessions away from it, and a UserDetailsService bean replaces Boot's generated user.
The users still live in code with {noop} passwords. The next article authenticates users from the database: a UserDetailsService that loads them from there, a PasswordEncoder with BCrypt, and registration and login.