Chương 3 và 4 đã xây dựng một API catalogue sản phẩm mà ai cũng gọi được: DELETE /api/products/1 chỉ cần mỗi URL. Bài 15 đã chốt một API được bảo vệ phải trả lời gì: 401 kèm header WWW-Authenticate khi server không biết ai đang gọi, 403 khi đã biết nhưng từ chối; bài 20 ghi chú rằng cả hai đến từ các filter của Spring Security, nơi @ExceptionHandler không nhìn thấy. Bài này mở đầu Chương 5 bằng việc thêm Spring Security vào catalogue.
Bài bắt đầu từ những gì riêng starter đã làm với ứng dụng đang chạy, lần theo một request qua filter chain tạo ra các response đó, rồi thay chain mặc định của Boot bằng một SecurityFilterChain tự viết và lần lượt gặp từng cái bẫy trên đường đi: một POST thất bại dù credentials đúng, một thứ tự rule âm thầm mở một endpoint, một chain thứ hai biến 401 của API thành redirect tới trang login. Các ví dụ dùng Spring Boot 4.1.1, kéo theo Spring Security 7.1.1, và Java 21, trên một project Initializr có các dependency web, validation và security. App chạy ở port 8133 thay vì 8080 mặc định, nên bạn sẽ thấy port này trong các lệnh curl.
![]()
Các security header mà Spring Security thêm vào mọi response chỉ hiện đầy đủ một lần rồi được lược khỏi các output curl -i phía sau; dòng log giữ pattern mặc định của Boot, trừ khi đoạn trích ghi rõ là đã rút gọn.
API catalogue dùng trong bài
API là catalogue của Chương 3 với store trong bộ nhớ, vì bài này không cần database. Tạo 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 demoSo với các project của Chương 3, file build.gradle được sinh ra có thêm một starter và starter test đi kèm:
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'
}Các class sản phẩm nằm trong 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);
}
}Advice của bài 20, nằm trong package common từ bài 21, được rút lại còn đúng handler mà bài này gặp:
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;
}
}Để thấy API trước khi có security, hai dòng security trong build.gradle được comment lại cho một lần 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}]spring-boot-starter-security thay đổi những gì
Với một project có sẵn, thay đổi chỉ là một starter và starter test của nó:
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>BOM của Boot 4.1.1 resolve nó thành spring-security-core, spring-security-web, spring-security-config và spring-security-crypto 7.1.1, cộng với module spring-boot-security của Boot. Bỏ comment hai dòng đó, build lại và chạy jar. Không dòng code nào thay đổi, vậy mà log khởi động có thêm một cảnh báo:
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 đã tạo một user tên user với password ngẫu nhiên, đổi sau mỗi lần khởi động. Mọi endpoint, kể cả GET /api/products trông như public, giờ đều đòi password này.
401 cho curl, redirect tới /login cho trình duyệt
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 GMTMột response 401 có challenge WWW-Authenticate: Basic và body rỗng, đúng status mà bài 15 yêu cầu. Cùng request đó nhưng gửi như trình duyệt:
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 GMTVới logging.level.org.springframework.security=DEBUG, log tự giải thích lần redirect này:
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 /loginChain mặc định của Boot bật cả form login lẫn HTTP Basic, và một DelegatingAuthenticationEntryPoint chọn một trong hai dựa trên header Accept của request. Với request có Accept: application/json, nó ghi No match found. Using default entry point org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint@3bcf9488. Set-Cookie ở đây không phải là login: HttpSessionRequestCache tạo một session để nhớ request, nhằm phát lại nó sau khi login thành công.
Accept gửi đi | Response | Entry point | Set-Cookie: JSESSIONID |
|---|---|---|---|
*/* (mặc định của curl) | 401, WWW-Authenticate: Basic realm="Realm", charset="UTF-8" | BasicAuthenticationEntryPoint | có, request đã được lưu |
text/html | 302, Location: http://localhost:8133/login | LoginUrlAuthenticationEntryPoint | có |
application/json | 401, cùng challenge | BasicAuthenticationEntryPoint | không, không có gì được lưu |
Trang login mặc định
/login do chính Spring Security phục vụ. Headless Chrome, điều khiển qua DevTools protocol, đã mở /api/products, submit form một lần với password sai và một lần với password được sinh ra. Các request cấp trang mà nó ghi lại:
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/jsonTrang có tiêu đề "Please sign in", chứa một form POST tới /login gồm field username, field password, một input ẩn _csrf mang token dài 96 ký tự và nút "Sign in", được style bởi /default-ui.css. Sau password sai, trang hiện "Invalid credentials". Sau password đúng, request đã lưu quay lại với ?continue, trình duyệt nhận JSON sản phẩm, và một cookie JSESSIONID (HttpOnly) giữ phiên login cho các request tiếp theo.
HTTP Basic với password được sinh tự động
Client của API thì gửi credentials kèm theo mỗi request:
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}]Body giống hệt lúc chưa có starter; sáu header từ X-Content-Type-Options tới X-Frame-Options là mới, có mặt ở mọi response và được lược bỏ từ đây. Request thành công không tạo session nào. Với -u user:wrong, response là cùng challenge 401 như khi không gửi credentials.
spring.security.user.name, password và roles
Một password đổi sau mỗi lần khởi động thì hết dùng được sau phút đầu tiên. User của Boot có thể cố định trong config:
spring.security.user.name=alice
spring.security.user.password=alice-secret
spring.security.user.roles=USERspring:
security:
user:
name: alice
password: alice-secret
roles: USERVới các property này, log khởi động không in password sinh tự động nữa, alice:alice-secret xác thực được, còn user cũ biến mất: curl -i -u user:alice-secret http://localhost:8133/api/me trả 401 với cùng challenge. /api/me là một endpoint nhỏ được thêm ở phần authentication bên dưới. Phần còn lại của bài dùng alice cho tới khi một user store thật thay cho các property.
Một request đi qua filter chain của Spring Security như thế nào
Spring Security không thuộc Spring MVC. Nó là một servlet filter mà Tomcat chạy trước DispatcherServlet, và bên trong là một danh sách filter có thứ tự của riêng nó.
DelegatingFilterProxy và FilterChainProxy
Với logging.level.org.springframework.boot.web.servlet=DEBUG, Boot liệt kê các filter nó đăng ký với 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 được map vào mọi URL ở order -100, giá trị mặc định của spring.security.filter.order, nên nó chạy sau các filter character encoding, form content và request context. Thứ Tomcat giữ là một DelegatingFilterProxy: Tomcat chỉ biết servlet filter, và proxy này chuyển từng request cho Spring bean mang tên đó. Một dòng tạm new Exception("call path").printStackTrace(System.out) trong ProductController.findAll() cho thấy toàn bộ đường đi của một GET /api/products đã xác thực. Đọc từ dưới lên:
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)ApplicationFilterChaincủa Tomcat gọiCharacterEncodingFilter,RequestContextFilter, rồi tớiDelegatingFilterProxy.DelegatingFilterProxygọi beanspringSecurityFilterChain. Trong Spring Security 7.1.1, bean đó làWebSecurityConfiguration$CompositeFilterChainProxy, chạyServletRequestPathFilterrồi tớiFilterChainProxy.FilterChainProxychọn mộtSecurityFilterChainvà chạy các filter của nó qua mộtVirtualFilterChain, filter sau lồng bên trong filter trước:DisableEncodeUrlFilterở dưới cùng,AuthorizationFilterở trên cùng.- Sau filter cuối cùng,
FilterChainProxy.lambda$doFilterInternal$2trả request về chain của Tomcat, chain này đi tớiHttpServlet.service,DispatcherServletvà controller.
Mọi quyết định security đều được đưa ra trước khi DispatcherServlet được gọi. Đó là lý do advice của bài 20 không bao giờ thấy chúng, điều mà một phần sau sẽ chứng minh bằng thực tế.
Các filter Spring Security in ra khi khởi động
FilterChainProxy có thể giữ nhiều SecurityFilterChain. Khi chỉ có starter, nó giữ chain mặc định của Boot, và mức DEBUG đặt ở trên in chain đó ra một lần lúc khởi động:
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, AuthorizationFilterMười sáu filter, áp dụng cho "any request". Thứ tự của chúng do Spring Security cố định, không phụ thuộc thứ tự bạn nhắc tới chúng trong config.

Từng filter của một request
Ở mức TRACE, FilterChainProxy ghi log từng filter nó gọi. Đây là GET /api/products đã xác thực của stack trace trên, mỗi dòng đã cắt timestamp, PID và thread, một số dòng được bỏ qua (...):
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/productsNhững filter cần nắm
Phần lớn filter trong danh sách không làm gì với request này. Sáu filter sau quyết định mọi kết quả trong bài:
SecurityContextHolderFilter(3) đưaSecurityContextvàoSecurityContextHoldercho phần còn lại của request. Việc nạp được trì hoãn: repository chỉ được hỏi khiBasicAuthenticationFilterlần đầu cần tới context (No HttpSession currently exists).CsrfFilter(5) bỏ quaGET,HEAD,TRACEvàOPTIONS, còn mọi method khác phải có CSRF token. Với request GET này, nó chỉ đặt một token vào request attribute.UsernamePasswordAuthenticationFilter(7) vàBasicAuthenticationFilter(11) là hai cách login. Cái đầu chỉ xử lýPOST /logintừ form, cái sau xử lý headerAuthorization: Basic. Khi thành công, chúng đặt mộtAuthenticationvàoSecurityContextHolder.AnonymousAuthenticationFilter(14) đặt mộtAnonymousAuthenticationTokenchoanonymousUservớiROLE_ANONYMOUSkhi chưa có gì xác thực request, để các filter phía sau không bao giờ gặpAuthenticationrỗng.ExceptionTranslationFilter(15) không làm gì trên đường vào. Nó bọc filter đứng sau và biến một lần bị từ chối thành 401 hoặc 403, như phần authentication sẽ cho thấy.AuthorizationFilter(16) kiểm tra các rule authorization cho request. Rule mặc định của Boot là "authenticated"; chỉ khi rule đạt, request mới đi tiếp tớiDispatcherServlet.
Số còn lại đóng vai phụ: HeaderWriterFilter ghi sáu security header, DefaultLoginPageGeneratingFilter, DefaultLogoutPageGeneratingFilter và DefaultResourcesFilter phục vụ trang login, trang logout và /default-ui.css, RequestCacheAwareFilter phát lại request đã lưu trước khi login (?continue), SecurityContextHolderAwareRequestFilter làm cho request.getUserPrincipal() và isUserInRole() trả lời theo Spring Security, còn DisableEncodeUrlFilter giữ session id khỏi lọt vào URL.
Để ý Granted Authorities=[] trên principal và FACTOR_PASSWORD trên token: user sinh tự động của Boot không có role nào, và Spring Security 7 ghi lại loại credential đã xác thực request. Phần authentication sẽ quay lại chi tiết này.
Tự viết SecurityFilterChain
Chain của Boot bảo vệ mọi URL theo cùng một cách và đưa ra một trang login mà không client API nào dùng. Catalogue cần các rule khác: ai cũng được đọc sản phẩm, chỉ admin được xóa, mọi request khác phải có credentials, và API dùng HTTP Basic, không có form login. Trong Spring Security 7.1.1, đó là một bean SecurityFilterChain:
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();
}
}HttpSecuritylà builder cho một chain, do Spring Security inject vào. Mỗi method nhận một lambda cấu hình một tính năng, vàhttp.build()tạo ra chain.authorizeHttpRequestsliệt kê các rule:requestMatchers(HttpMethod.GET, "/api/products/**")khớp danh sách lẫn từng sản phẩm,permitAll()cho mọi người đi qua,hasRole("ADMIN")đòi authorityROLE_ADMIN, cònanyRequest().authenticated()bao phủ mọi thứ mà các rule phía trên chưa nhắc tới.httpBasic(Customizer.withDefaults())giữ lạiBasicAuthenticationFilter;formLogin(form -> form.disable())nói rõ là không có form login.
Kiểu lambda này là kiểu duy nhất Spring Security 7.1.1 cung cấp. WebSecurityConfigurerAdapter đã bị gỡ từ Spring Security 6.0, và các jar 7.1.1 không còn antMatchers, mvcMatchers, authorizeRequests() hay and(). HttpSecurity.build() được khai báo là public final O build(), không có throws Exception, nên bean method cũng không cần mệnh đề throws.
Chain sau khi khởi động lại, theo dòng DEBUG:
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, AuthorizationFilterCòn mười hai filter: filter form login và ba filter đứng sau các trang mặc định đã biến mất. Các rule khi chạy:
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"}Một request public không có credentials đã tới được controller, và advice vẫn trả ProblemDetail của nó. GET /api/products cũng trả 200 theo cách đó. Một endpoint được bảo vệ, không có 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: 0Với -H 'Accept: text/html', response vẫn là 401 thay vì redirect, và GET /login cũng trả 401: không còn trang login nào để redirect tới.
Spring Boot 4 có cần @EnableWebSecurity không?
Không. SecurityConfig ở trên chỉ có @Configuration, và chain của nó chính là chain đã chạy. Boot tự thêm annotation đó: ServletWebSecurityAutoConfiguration.EnableWebSecurityConfiguration trong spring-boot-security 4.1.1 được gắn @EnableWebSecurity và có hiệu lực khi chưa có bean springSecurityFilterChain nào. Conditions report của một lần chạy với --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)Chain mặc định của Spring Boot tự lui ra
Cũng report đó giải thích vì sao chain mười sáu filter không còn:
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 chỉ định nghĩa defaultSecurityFilterChain khi không có bean nào kiểu SecurityFilterChain. Ở lần chạy chỉ có starter, cũng condition này ghi did not find any beans và configuration được áp dụng. Ngay khi bạn có một chain, chain của Boot không được tạo: không rule nào của nó còn lại, và không có chuyện gộp rule.
Vì sao POST thất bại dù credentials đúng: CSRF
Chain đã sẵn sàng, alice tạo một sản phẩm:
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 GMTMột response 401 cho chính credentials vừa dùng được một giây trước với GET /api/me. DELETE /api/products/2 với cùng credentials cũng nhận 401 y như vậy.
Lỗi 403 đến tay client thành 401
Log DEBUG của lần 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@28a59257CsrfFiltertừ chối request bằng 403. Nó là filter thứ 5;BasicAuthenticationFilterlà filter thứ 7 trong chain này, nên credentials của alice chưa hề được kiểm tra.AccessDeniedHandlerImpltrả lời bằng cách gọisendError(403).- Tomcat chuyển lỗi tới
/error, và lần ERROR dispatch đó lại đi qua security chain thêm một lần nữa. /errorlà anonymous.BasicAuthenticationFilterkế thừaOncePerRequestFilter, màshouldNotFilterErrorDispatch()của class này trảtruetrong spring-web 7.0.9 và không bị override, nên nó cũng không chạy trong ERROR dispatch.anyRequest().authenticated()từ chối/error, và Basic entry point ghi đè 403 bằng 401.
Chain mặc định của Boot cũng làm y hệt: cùng POST đó với password sinh tự động, gửi tới ứng dụng chỉ có starter, cũng nhận về 401. Cho ERROR dispatch đi qua sẽ lộ ra status thật:
.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"}Log giờ kết thúc bằng Secured GET /error, và error controller của Boot ghi body JSON. Rule này chỉ để chẩn đoán và được gỡ đi lại; cấu hình cuối cùng tự ghi response 401 và 403 mà không cần ERROR dispatch.
CSRF, cross-site request forgery, là kiểu tấn công mà một website khác khiến trình duyệt của user đang đăng nhập gửi đi một request làm thay đổi dữ liệu, dựa vào việc trình duyệt tự đính kèm session cookie của user đó. Cách phòng thủ của Spring Security là một token mà site kia không đọc được, bắt buộc với mọi request trừ GET, HEAD, TRACE và OPTIONS, và curl không gửi token nào.
Tắt CSRF cho API stateless
.httpBasic(Customizer.withDefaults())
.formLogin(form -> form.disable());
.formLogin(form -> form.disable())
.csrf(csrf -> csrf.disable()); ⚠️ Tắt CSRF chỉ đúng với API này vì không có gì trong nó tự động xác thực một request: mọi lời gọi mang credentials trong header
Authorization, không có form login, và phần tiếp theo còn gỡ luôn session cookie. Một chain giữ phiên login bằng cookie, như chain form login ở phần sau của bài, phải giữ CSRF bật. Bài 36 sẽ nói rõ khi nào áp dụng trường hợp nào và cách dùng token.
Cùng POST đó sau khi khởi động lại:
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 và authorization: 401 và 403
Authentication trả lời câu hỏi ai đang gọi; authorization trả lời người gọi này có được làm việc này không. Spring Security giải quyết câu thứ nhất trước khi hỏi câu thứ hai, và mỗi lần thất bại có status riêng. Với chain ở trên, một POST không có 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 GMTCùng POST đó với -u alice:wrong nhận cùng 401, và log cho thấy nó dừng ở đâu:
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 credentialsCredentials hợp lệ, nhưng alice không thỏa một rule: cô có ROLE_USER còn DELETE đòi 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 /errorResponse 403 có body còn 401 thì không, dù cả hai đều đi qua sendError và một ERROR dispatch. Với alice, lần dispatch tới /error đã được xác thực (Secured GET /error), nên error controller của Boot ghi JSON; với request anonymous, /error lại bị từ chối và body vẫn rỗng.

ExceptionTranslationFilter, AuthenticationEntryPoint và AccessDeniedHandler
AuthorizationFilter không ghi response. Khi một rule không đạt, nó ném AuthorizationDeniedException, và ExceptionTranslationFilter, lớp bọc bên ngoài nó, quyết định ý nghĩa của lần từ chối dựa trên caller trong SecurityContextHolder. Với logging.level.org.springframework.security.web.access.ExceptionTranslationFilter=TRACE, nó ghi rõ nhánh đã chọn. Với GET /api/products anonymous ở lần chạy chỉ có starter:
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 DeniedVới lệnh DELETE của alice trong cấu hình cuối cùng ở cuối bài, đã cắt phần đầu dòng và rút gọn chi tiết token thành […]:
TRACE o.s.s.w.a.ExceptionTranslationFilter : Sending UsernamePasswordAuthenticationToken […] to access denied handler since access is denied
org.springframework.security.authorization.AuthorizationDeniedException: Access Denied- Caller anonymous được đưa tới
AuthenticationEntryPoint. Việc của nó là bắt đầu authentication:BasicAuthenticationEntryPointtrả 401 kèm challenge,LoginUrlAuthenticationEntryPointredirect tới trang login. - Caller đã xác thực được đưa tới
AccessDeniedHandler. Login lại cũng không đổi được kết quả, nênAccessDeniedHandlerImpltrả 403. - Password sai không bao giờ tới được
AuthorizationFilter.BasicAuthenticationFiltertự bắtBadCredentialsExceptionvà gọi entry point của chính nó, vì vậyFailed to process authentication requestlà dòng Spring Security cuối cùng trước response 401.
SecurityContextHolder chứa gì sau khi login
Cách dễ nhất để xem một lần login thành công để lại gì là nhìn từ controller. GET /api/me nhận Authentication và principal làm parameter rồi so sánh chúng với 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]]]SecurityContextHoldergiữ mộtSecurityContextmàAuthenticationbên trong làUsernamePasswordAuthenticationToken. Parameter của controller chính là object đó (sameAsSecurityContextHolder: true).- Principal là một
UserDetails, ở đây là classUsercủa Spring Security, mang các role lúc tạo:ROLE_USER, từspring.security.user.roles=USER. - Credentials đã biến mất.
getCredentials()trảnullsau khi xác thực, nên password không đi sâu thêm vào ứng dụng. - Authorities của token có thêm
FACTOR_PASSWORD. Spring Security 7 thêm mộtFactorGrantedAuthorityghi loại credential đã xác thực request, cùng thời điểm cấp.spring-security-config7.1.1 cũng có@EnableMultiFactorAuthentication; multi-factor authentication thuộc khóa Advanced. Detailsghi địa chỉ remote và session id, ở đây lànullvì HTTP Basic không tạo session.
API stateless với SessionCreationPolicy.STATELESS
Nhiều response 401 ở trên đặt cookie JSESSIONID cho một client không bao giờ login qua session. Một setting chặn được việc đó:
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)); POST không có credentials, trước và sau:
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: 0Dòng log Saved request http://localhost:8133/api/products?continue to session biến mất cùng với cookie: một chain stateless không lưu request cho một trang login mà nó không có. SessionManagementFilter xuất hiện thêm trong danh sách filter lúc khởi động. Điều không đổi: một request Basic thành công không tạo session ở cả hai cấu hình. curl -c ghi ra một cookie jar rỗng sau GET /api/me với alice ở cả hai, và khi chưa có STATELESS, một request tiếp theo dùng jar đó mà không có credentials nhận về 401 bình thường. HTTP Basic gửi credentials ở mọi request, nên không có session nào cần giữ, và STATELESS bảo đảm không có session nào vô tình xuất hiện.
Thứ tự requestMatchers: rule khớp đầu tiên thắng
AuthorizationFilter kiểm tra các rule từ trên xuống và dùng rule đầu tiên có matcher khớp với request. Nó không đi tìm rule cụ thể nhất. Ba lần chạy tiếp theo chỉ đổi phần rule của chain stateless.
Thứ tự rule vô tình khóa một endpoint
Một rule cho toàn bộ API được thêm lên trên rule public:
.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 khớp /api/** trước, nên permitAll() bên dưới không bao giờ được áp dụng, và danh sách sản phẩm giờ đòi credentials (-u alice:alice-secret nhận 200). Ứng dụng khởi động mà không có một dòng WARN nào.
Thứ tự rule vô tình mở một endpoint
Lỗi ngược lại còn tệ hơn. Rule public viết thiếu method, đặt trên rule admin:
.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/2Một caller anonymous đã xóa sản phẩm 2; lệnh GET /api/products tiếp theo chỉ còn liệt kê bàn phím. hasRole("ADMIN") vẫn nằm trong config nhưng không bao giờ chạy. Hãy đặt rule hẹp trên rule rộng, giữ HTTP method trong những rule chỉ dành cho một method, và kết thúc bằng anyRequest(). Vị trí cuối cùng đó là điều duy nhất configurer bắt buộc: AbstractRequestMatcherRegistry trong jar 7.1.1 chứa message Can't configure requestMatchers after anyRequest.
permitAll() và anonymous()
Rule public dùng anonymous() thay cho permitAll():
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/products/**").anonymous()
.requestMatchers(HttpMethod.DELETE, "/api/products/**").hasRole("ADMIN")
.anyRequest().authenticated())Không có credentials, GET /api/products trả 200. Với credentials của alice:
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() cho mọi caller đi qua. anonymous() chỉ cho qua request mà AnonymousAuthenticationFilter đã đánh dấu là anonymousUser, nên user đã login bị từ chối; nó hợp với trang đăng ký, không hợp với một catalogue public. Không rule nào bỏ qua credentials được gửi kèm: với permitAll(), curl -i -u alice:wrong http://localhost:8133/api/products trả 401, vì BasicAuthenticationFilter từ chối header Authorization sai trước khi rule nào được kiểm tra.
Hai SecurityFilterChain với securityMatcher và @Order
Phần lớn ứng dụng còn phục vụ những thứ khác ngoài API: một trang admin, một site render phía server như ở bài 24. Những phần đó cần form login, session và CSRF, tức là đúng những thứ chain API đã tắt. Thay vì một chain thỏa hiệp, hãy định nghĩa hai chain:
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/**")quyết định chain có xử lý một request hay không.requestMatchersbên trongauthorizeHttpRequestschỉ quyết định rule trong phạm vi chain đó.@Orderquyết định thứ tựFilterChainProxyhỏi các chain. Chain web không cósecurityMatcher, nên nó khớp mọi request.
Log khởi động in cả hai chain:
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, AuthorizationFilterChain API không có CsrfFilter cũng không có UsernamePasswordAuthenticationFilter, dù nó không hề gọi formLogin(form -> form.disable()): một chain dựng từ HttpSecurity chỉ có form login khi nó gọi formLogin(). Dòng đó trong phiên bản một chain chỉ ghi lại một quyết định chứ không làm thay đổi danh sách filter. Chain web có các filter login và CsrfFilter, không có BasicAuthenticationFilter.
Chain nào đã xử lý request
Với logging.level.org.springframework.security.web.FilterChainProxy=TRACE, mỗi request ghi lại các chain mà nó được so khớp. Phần đầu dòng đã được cắt và danh sách filter thay bằng […]:
### 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 khớp chain thứ nhất và chain thứ hai không bao giờ được hỏi. / không khớp /api/**, nên FilterChainProxy chuyển sang chain thứ hai, chain khớp mọi thứ, và entry point form login của nó redirect tới /login, kể cả khi có -u alice:alice-secret, vì chain này không đọc Basic credentials.
![FilterChainProxy chọn chain như thế nào: GET /api/products được thử với apiSecurityFilterChain có @Order(1) và matcher Or [PathPattern [/api/**]], khớp và nhận HTTP/1.1 200 mà webSecurityFilterChain không được hỏi tới; GET / không khớp chain thứ nhất, khớp webSecurityFilterChain có @Order(2) cho mọi request và nhận HTTP/1.1 302 tới http://localhost:8133/login; khi đổi giá trị @Order cho nhau, webSecurityFilterChain khớp mọi request trước, apiSecurityFilterChain không bao giờ được gọi tới, và ứng dụng thất bại ngay khi khởi động với UnreachableFilterChainException](/images/blog/sb-securityfilterchain-matching.vi.webp)
Lỗi 401 biến thành redirect tới /login
Chain API không đổi gì, vậy mà một lời gọi API chưa xác thực không còn nhận được 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=AE729E73CC623615A92322DA476669D5Chain API thực ra đã trả 401: header WWW-Authenticate của nó vẫn nằm trong response. Nhưng Basic entry point dùng sendError, ERROR dispatch đi tới /error, và /error không nằm dưới /api/**. Chain web nhận nó, thấy caller anonymous, và entry point form login thay 401 bằng một redirect. Session id trong Location cũng đến từ lần dispatch đó: DisableEncodeUrlFilter, filter giữ session id khỏi URL, cũng là một OncePerRequestFilter không override shouldNotFilterErrorDispatch(), nên nó cũng không chạy cho /error. Chuyện tương tự xảy ra với -H 'Accept: text/html' và với password sai. Một lần từ chối caller đã xác thực thì vẫn đúng: lệnh DELETE /api/products/2 của alice trả 403 với body JSON của Boot, vì ERROR dispatch của cô đã được xác thực. Phần tiếp theo loại hẳn ERROR dispatch khỏi các response của API.
Đảo giá trị @Order
Với @Order(1) trên chain web và @Order(2) trên chain API, ứng dụng không khởi động được:
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.Khác với rule trong một chain, thứ tự chain có lưới an toàn cho trường hợp này: một chain khớp mọi request đứng trước một chain khác sẽ bị từ chối ngay lúc khởi động. Message nói về "any request"; với các chain có matcher hẹp hơn nhưng chồng lên nhau, như /api/** và /api/products/**, hãy cho chain hẹp hơn giá trị @Order nhỏ hơn.
Trả 401 và 403 dưới dạng ProblemDetail
API giờ trả 401 với body rỗng hoặc một redirect, và 403 với JSON mặc định của Boot. Bài 20 đã cho mọi lỗi khác một ProblemDetail theo RFC 9457 với Content-Type: application/problem+json.
Vì sao @RestControllerAdvice không bao giờ thấy chúng
Cách thử đầu tiên ai cũng nghĩ tới là thêm hai handler vào 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);
} Các lần chạy hai chain ở phần trước đã có sẵn hai handler này trong class: redirect 302 cho /api/me, redirect 302 cho password sai và 403 với JSON của Boot chính là kết quả của chúng, và cả handleAuthentication called lẫn handleAccessDenied called đều không xuất hiện trong log. AuthenticationException và AuthorizationDeniedException được ném ra và xử lý ngay bên trong các security filter, trước khi DispatcherServlet được gọi, trong khi method @ExceptionHandler chỉ thấy exception từ các handler method mà DispatcherServlet đã gọi. Hãy gỡ hai handler này; chỗ để định hình các response đó là entry point và access denied handler.
Entry point và access denied handler ghi ProblemDetail
Một component implement cả hai interface và tự ghi response, dùng JsonMapper mà Boot cấu hình cho 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);
}
}commencelà method củaAuthenticationEntryPoint. Nó tự đặtWWW-Authenticate, vì một response 401 thiếu header này không cho client biết phải xác thực bằng cách nào.handlelà method củaAccessDeniedHandler.- Không method nào gọi
sendError. Response được ghi và commit ngay tại chỗ, nên không có ERROR dispatch nào để một chain khác chen vào. detaillà chuỗi cố định. Message của exception có thể cho caller biết user name có tồn tại hay password bị sai.
AccessDeniedException là kiểu parameter của interface; AuthorizationDeniedException kế thừa nó. Gắn component vào chain API qua 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"}Cả hai body có cùng dạng với các handler của bài 20: các member chuẩn theo thứ tự alphabet, đường dẫn request làm instance, reason phrase làm title. Password sai thì không:
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 GMTEntry point mà BasicAuthenticationFilter dùng
realm="Realm" đã tố cáo nó: response 401 đó đến từ BasicAuthenticationEntryPoint mặc định chứ không phải component mới, và lệnh sendError của nó lại đẩy request sang chain web. exceptionHandling cấu hình ExceptionTranslationFilter, còn password sai thì không bao giờ đi tới đó; BasicAuthenticationFilter tự gọi entry point của httpBasic. Nó cần cùng 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"}Trong cùng lần chạy, curl -i -u alice:wrong http://localhost:8133/api/products nhận cùng body 401 với "instance":"/api/products", -H 'Accept: text/html' trên /api/me nhận cùng 401 thay vì redirect, còn lệnh DELETE của alice vẫn nhận 403 như trên. Mọi response security của API giờ đều là ProblemDetail, và 401 vẫn giữ header WWW-Authenticate.
User trong bộ nhớ với InMemoryUserDetailsManager
spring.security.user.* chỉ cấu hình được một user. Hai user với role khác nhau cần một UserDetailsService, và lúc này bản trong bộ nhớ là đủ. SecurityConfig hoàn chỉnh:
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);
}
}Các dòng spring.security.user.* được gỡ khỏi application.properties:
spring.application.name=demo
spring.security.user.name=alice
spring.security.user.password=alice-secret
spring.security.user.roles=USER roles("ADMIN") lưu authority ROLE_ADMIN, đúng thứ mà hasRole("ADMIN") kiểm tra. Hai user với rule admin:
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}Password sinh tự động đi đâu
Nó biến mất, và không phải vì các property đã bị gỡ: log khởi động của lần chạy này không có dòng Using generated security password nào, và conditions report, với --debug, cho biết lý do:
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 chỉ tạo user của nó, sinh tự động hay lấy từ spring.security.user.*, khi ứng dụng chưa định nghĩa UserDetailsService, AuthenticationProvider, AuthenticationManager, AuthenticationManagerResolver hay JwtDecoder nào. Với bean ở trên, các property kia sẽ bị bỏ qua dù có còn nằm trong file.
Tiền tố password và withDefaultPasswordEncoder()
Password lưu cho DelegatingPasswordEncoder mặc định của Spring Security bắt đầu bằng id của encoder đã tạo ra nó: {noop} nghĩa là phần còn lại là plain text được so sánh nguyên văn, {bcrypt} nghĩa là phần còn lại là một BCrypt hash. Boot dựa vào cùng cơ chế đó cho user của mình: trong UserDetailsServiceAutoConfiguration 4.1.1, một password lấy từ property mà chưa có tiền tố {id} sẽ được gắn hằng NOOP_PASSWORD_PREFIX = "{noop}" vào đầu khi ứng dụng không có bean PasswordEncoder. Một đoạn probe chạy lúc khởi động trong cùng ứng dụng in ra kết quả của từng dạng:
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() hash password bằng BCrypt ngay lúc tạo user, nhưng nó bị đánh dấu deprecated trong spring-security-core 7.1.1 (javap -v hiện Deprecated: true), và bytecode của nó mang cảnh báo "User.withDefaultPasswordEncoder() is considered unsafe for production and is only intended for sample applications": password dạng plain text vẫn nằm trong source code. {noop} thẳng thắn về đúng điều đó và ổn cho một bản demo. Bài 34 chuyển user vào database với BCrypt hash.
Tổng hợp các thành phần của Spring Security
Mỗi dòng dưới đây là thứ bài này đã gặp khi chạy.
| Thành phần | Trách nhiệm | Chỗ bạn đụng tới |
|---|---|---|
DelegatingFilterProxy | servlet filter mà Tomcat chạy với tên springSecurityFilterChain, order -100; chuyển từng request cho Spring bean | spring.security.filter.order; thường không cần đụng |
FilterChainProxy | chạy SecurityFilterChain đầu tiên có matcher khớp request | logging.level.org.springframework.security.web.FilterChainProxy=TRACE |
SecurityFilterChain | một danh sách filter có thứ tự cho các request mà securityMatcher của nó chọn | một @Bean dựng từ HttpSecurity, với @Order và securityMatcher |
SecurityContextHolderFilter | đưa SecurityContext vào cho request | sessionManagement(...) quyết định có dùng session hay không |
CsrfFilter | từ chối request không có CSRF token, trừ GET, HEAD, TRACE và OPTIONS | csrf(...) |
BasicAuthenticationFilter | xác thực header Authorization: Basic; khi thất bại gọi entry point của chính nó | httpBasic(...) |
UsernamePasswordAuthenticationFilter | xác thực POST /login từ form login | formLogin(...) |
AnonymousAuthenticationFilter | đặt anonymousUser với ROLE_ANONYMOUS khi chưa ai được xác thực | các rule anonymous() khớp với nó |
ExceptionTranslationFilter | đưa caller anonymous bị từ chối tới entry point, caller đã xác thực bị từ chối tới access denied handler | exceptionHandling(...) |
AuthorizationFilter | kiểm tra các rule của authorizeHttpRequests, rule khớp đầu tiên thắng | requestMatchers, permitAll(), anonymous(), hasRole(), authenticated(), anyRequest() |
AuthenticationEntryPoint | trả lời "bạn là ai?" bằng challenge 401 hoặc redirect tới trang login | exceptionHandling(...) và httpBasic(...) |
AccessDeniedHandler | trả lời "không được phép" bằng 403 | exceptionHandling(...) |
SecurityContextHolder, Authentication | caller hiện tại: principal, authorities, details | parameter Authentication và @AuthenticationPrincipal trong controller |
UserDetailsService | nạp user theo tên; khi có bean này, user của Boot bị tắt | bean InMemoryUserDetailsManager ở bài này, database ở bài 34 |
DelegatingPasswordEncoder | so sánh password dựa trên tiền tố {id} | {noop} ở bài này, BCrypt ở bài 34 |
FAQ
Vì sao Spring Security trả 401 thay vì hiện trang login?
Vì request không yêu cầu HTML. Chain mặc định của Boot có cả form login lẫn HTTP Basic, và entry point của nó chọn dựa trên header Accept: curl với Accept: */* hoặc application/json nhận 401 kèm WWW-Authenticate: Basic realm="Realm", charset="UTF-8", còn Accept: text/html nhận 302 tới /login. Một chain tự viết chỉ có httpBasic() trả 401 cho cả hai.
WebSecurityConfigurerAdapter còn dùng được trong Spring Security 7 không?
Không. Nó đã bị gỡ từ Spring Security 6.0, và jar spring-security-config 7.1.1 không có class nào tên như vậy, không có antMatchers hay mvcMatchers, không có authorizeRequests() và không có and(). Security được cấu hình bằng các bean SecurityFilterChain dựng từ HttpSecurity với lambda, và requestMatchers thay cho các method matcher cũ.
Vì sao POST trả 403 hoặc 401 dù credentials đúng?
CSRF protection bật mặc định và từ chối mọi POST, PUT, PATCH và DELETE không có CSRF token, trước cả khi credentials được kiểm tra. CsrfFilter trả 403, nhưng với HTTP Basic, ERROR dispatch tới /error chạy dưới dạng anonymous, nên một chain không permit /error biến nó thành 401. Dòng DEBUG Invalid CSRF token found chỉ ra nguyên nhân. Với API stateless gửi credentials trong header, hãy tắt CSRF; với bất cứ thứ gì login bằng cookie, hãy gửi token.
@RestControllerAdvice có xử lý được AccessDeniedException của Spring Security không?
Không, với các rule do security filter kiểm tra. Lần từ chối được ném ra và xử lý bên trong filter chain trước khi DispatcherServlet chạy; các handler cho AuthenticationException và AccessDeniedException trong advice không bao giờ được gọi trong các lần chạy của bài này. Hãy định hình các response đó bằng một AuthenticationEntryPoint và một AccessDeniedHandler, và đặt entry point cho cả httpBasic(...) lẫn exceptionHandling(...).
permitAll() và anonymous() khác nhau thế nào?
permitAll() cho mọi caller đi qua. anonymous() chỉ cho qua caller mà AnonymousAuthenticationFilter đã đánh dấu là anonymousUser: GET /api/products với anonymous() trả 200 khi không có credentials và 403 với credentials hợp lệ của alice. Với cả hai rule, request mang password sai bị BasicAuthenticationFilter từ chối bằng 401 trước khi rule được kiểm tra.
FACTOR_PASSWORD trong authorities là gì?
Là một FactorGrantedAuthority mà Spring Security 7 thêm vào Authentication để ghi lại rằng caller đã xác thực bằng password, cùng thời điểm cấp. Response /api/me của alice liệt kê ["ROLE_USER","FACTOR_PASSWORD"] dù cô chỉ được tạo với ROLE_USER. Nó không làm thay đổi các rule hasRole: alice vẫn nhận 403 còn bob nhận 204 với lệnh DELETE khi authority này có trong danh sách. Multi-factor authentication, tức @EnableMultiFactorAuthentication trong 7.1.1, thuộc khóa Advanced.
Vì sao SecurityFilterChain thứ hai không bao giờ chạy?
Vì một chain đứng trước đã khớp các request của nó. FilterChainProxy chỉ dùng chain đầu tiên có securityMatcher khớp, theo @Order. Nếu chain đứng trước không có securityMatcher, nó khớp mọi request, và Spring Security 7.1.1 từ chối khởi động với UnreachableFilterChainException. Hãy cho chain hẹp hơn giá trị @Order nhỏ hơn và để chain không có securityMatcher ở cuối cùng.
Kết luận
Spring Security là một servlet filter đứng trước Spring MVC: Tomcat gọi DelegatingFilterProxy, proxy gọi FilterChainProxy, và FilterChainProxy chạy SecurityFilterChain đầu tiên khớp request cùng các filter có thứ tự của nó, trước khi DispatcherServlet thấy bất cứ điều gì. Riêng starter đã khóa mọi endpoint sau một user sinh tự động, trả 401 cho client API và redirect trình duyệt tới trang login. Một bean SecurityFilterChain tự viết thay hẳn chain của Boot, không cần @EnableWebSecurity, và trong Spring Security 7.1.1 chỉ viết được bằng lambda DSL. Authentication thất bại tới AuthenticationEntryPoint thành 401, authorization thất bại tới AccessDeniedHandler thành 403, và ExceptionTranslationFilter chọn giữa hai nhánh dựa trên caller trong SecurityContextHolder.
Các cái bẫy đều xoay quanh thứ tự và dispatch. Rule và chain đều theo nguyên tắc khớp đầu tiên thắng, và một thứ tự rule sai có thể âm thầm mở lệnh DELETE cho bất kỳ ai. CSRF từ chối một POST trước khi credentials được kiểm tra, rồi ERROR dispatch có thể biến 403 đó thành 401, hoặc một chain thứ hai có thể biến 401 của API thành redirect. Một entry point và một access denied handler tự ghi ProblemDetail, đặt cho cả httpBasic lẫn exceptionHandling, cho API những response 401 và 403 nhất quán mà vẫn giữ WWW-Authenticate. STATELESS giữ session tránh xa API, và một bean UserDetailsService thay cho user sinh tự động của Boot.
User vẫn đang nằm trong code với password {noop}. Bài tiếp theo xác thực người dùng từ database: UserDetailsService nạp user từ database, PasswordEncoder với BCrypt, và đăng ký, đăng nhập.