Multiple SecurityFilterChain & Stateless JWT — tách chain theo nhóm endpoint
Một app Spring có thể chạy song song nhiều SecurityFilterChain bean với @Order + securityMatcher để áp dụng policy khác nhau cho từng nhóm URL. Bài này bóc cơ chế FilterChainProxy chọn chain, cách cấu hình stateless cho JWT REST API (SessionCreationPolicy.STATELESS + csrf disable), và WebSecurityCustomizer để bypass static resources.
TL;DR: FilterChainProxy giữ một danh sách SecurityFilterChain, duyệt theo @Order từ thấp đến cao, dừng ở chain đầu tiên có securityMatcher khớp URL — chain đó xử lý request, các chain sau bị bỏ qua. Mỗi chain cấu hình độc lập: /api/** dùng JWT stateless (SessionCreationPolicy.STATELESS + csrf disable), /admin/** dùng strict role, web form login dùng session. Chain cuối không cần securityMatcher — đóng vai catch-all. WebSecurityCustomizer.ignoring() bypass hẳn filter stack cho static resources. Cơ chế này cho phép API JWT và web form login cùng sống trong một app mà không tranh nhau config.
1. Vấn đề thực tế — khi một chain không đủ
Hãy xét app TaskFlow mà chúng ta đang xây: cùng một Spring Boot process cần phục vụ ba nhóm URL với policy hoàn toàn khác nhau:
| Nhóm URL | Policy | Xác thực |
|---|---|---|
/api/** | Stateless, CSRF off | JWT Bearer token |
/admin/** | Stateless, chỉ ROLE_ADMIN, audit log | JWT Bearer + strict role |
/static/**, /css/**, /js/** | Không cần security | Bypass filter hoàn toàn |
Nếu gộp cả ba vào một SecurityFilterChain, config trở thành một mớ điều kiện phức tạp, khó đọc, và dễ xung đột — ví dụ, SessionCreationPolicy chỉ set được một lần cho cả chain. Giải pháp của Spring Security là nhiều SecurityFilterChain bean, mỗi cái chịu trách nhiệm một nhóm URL.
2. Cơ chế bên dưới — FilterChainProxy chọn chain như thế nào
FilterChainProxy là servlet filter thật sự được đăng ký với container. Bên trong nó giữ List<SecurityFilterChain> đã sắp xếp theo @Order. Với mỗi request đến, nó thực hiện một thuật toán đơn giản:
flowchart TB Req["HTTP Request /api/orders/42"] FCP["FilterChainProxy<br/>doc danh sach chains theo @Order"] C1["Chain @Order(1)<br/>securityMatcher /admin/**<br/>KHONG khop"] C2["Chain @Order(2)<br/>securityMatcher /api/**<br/>KHOP -- chon chain nay"] C3["Chain @Order(3)<br/>khong co securityMatcher<br/>catch-all -- bo qua"] Filters["Chay toan bo filter cua Chain @Order(2)<br/>BearerTokenFilter -> AuthorizationFilter -> ..."] Done["Xu ly xong -- tra response"] Req --> FCP FCP --> C1 C1 -->|"no match"| C2 C2 -->|"match -- DUNG"| Filters C2 -.->|"cac chain con lai bi skip"| C3 Filters --> Done
Điểm cốt lõi: một request chỉ đi qua đúng một chain. Chain được chọn là chain đầu tiên (theo @Order) mà securityMatcher của nó khớp URL. Nếu không chain nào khớp, FilterChainProxy trả 403 mặc định.
securityMatcher là cổng vào của chain — nó không phải authorizeHttpRequests. securityMatcher quyết định chain này có nhận request không; authorizeHttpRequests bên trong chain mới quyết định request có được phép qua không.
securityMatcher: filter-level — chain này áp dụng cho URL nào? Nếu không khớp, cả chain bị bỏ qua.
requestMatchers (trong authorizeHttpRequests): authorization-level — URL này cần role/auth gì? Chỉ chạy sau khi chain đã được chọn.
3. Cấu hình nhiều SecurityFilterChain với @Order
Đây là pattern đầy đủ cho TaskFlow — ba chain, mỗi chain một trách nhiệm rõ:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
// Chain 1 -- Admin: JWT strict + @Order thap nhat = uu tien cao nhat
@Bean
@Order(1)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/admin/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.anyRequest().hasRole("ADMIN")
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
.exceptionHandling(eh -> eh
.authenticationEntryPoint(jsonEntryPoint())
.accessDeniedHandler(jsonAccessDeniedHandler())
);
return http.build();
}
// Chain 2 -- REST API: JWT stateless
@Bean
@Order(2)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
.exceptionHandling(eh -> eh
.authenticationEntryPoint(jsonEntryPoint())
.accessDeniedHandler(jsonAccessDeniedHandler())
);
return http.build();
}
// Chain 3 -- catch-all: khong co securityMatcher
// Web form login hoac cac URL khac
@Bean
@Order(3)
public SecurityFilterChain defaultChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/error").permitAll()
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.permitAll()
)
.logout(logout -> logout
.logoutSuccessUrl("/login?logout")
);
return http.build();
}
@Bean
public AuthenticationEntryPoint jsonEntryPoint() {
return (req, res, ex) -> {
res.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
res.setContentType("application/problem+json");
res.getWriter().write(
"{\"status\":401,\"title\":\"Unauthorized\",\"instance\":\"" +
req.getRequestURI() + "\"}");
};
}
@Bean
public AccessDeniedHandler jsonAccessDeniedHandler() {
return (req, res, ex) -> {
res.setStatus(HttpServletResponse.SC_FORBIDDEN);
res.setContentType("application/problem+json");
res.getWriter().write(
"{\"status\":403,\"title\":\"Forbidden\",\"instance\":\"" +
req.getRequestURI() + "\"}");
};
}
}
Điểm cần chú ý:
@Order(1)là ưu tiên cao nhất (số nhỏ = xét trước).- Chain cuối (
@Order(3)) không cósecurityMatcher→ đây là catch-all: nhận mọi URL không khớp chain trên. - Mỗi chain gọi
http.build()riêng — chúng hoàn toàn độc lập, không chia sẻ config.
4. Cấu hình stateless cho JWT REST API — tại sao và cái gì
Khi REST API dùng JWT, có hai thứ bắt buộc phải cấu hình: tắt session và tắt CSRF. Cả hai đều xuất phát từ cùng một nguyên tắc thiết kế.
4.1 SessionCreationPolicy.STATELESS — tại sao JWT không cần session
HTTP session trong Spring Security lưu SecurityContext vào HttpSession — khi request xong, context được ghi vào session, request tiếp theo đọc lại từ session để biết user là ai. Đây là cơ chế của web app truyền thống.
JWT hoạt động khác hẳn: mỗi request mang token trong header Authorization: Bearer <jwt>. BearerTokenAuthenticationFilter đọc token, validate signature + expiry, rồi dựng SecurityContext ngay tại đó — từ thông tin trong token, không cần session. Sau khi response, context bị garbage collected.
flowchart LR
subgraph Session["Web session (stateful)"]
direction TB
R1["Request 1<br/>login + password"] -->|"tao SecurityContext"| S["HttpSession<br/>(luu SecurityContext)"]
R2["Request 2<br/>khong co credential"] -->|"doc SecurityContext tu session"| S
end
subgraph JWT["JWT stateless"]
direction TB
R3["Request A<br/>Bearer eyJ..."] -->|"validate token<br/>dung SecurityContext"| GC1["GC sau response"]
R4["Request B<br/>Bearer eyJ..."] -->|"validate token<br/>dung SecurityContext moi"| GC2["GC sau response"]
endKhi đặt SessionCreationPolicy.STATELESS, Spring Security không tạo HttpSession mới và không đọc SecurityContext từ session có sẵn — mỗi request hoàn toàn tự lập. Đây là điều kiện tiên quyết để scale horizontally: bất kỳ pod nào cũng xử lý được bất kỳ request nào vì không có session state cần chia sẻ.
http.sessionManagement(s ->
s.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
4.2 csrf(csrf -> csrf.disable()) — tại sao REST API không cần CSRF protection
CSRF (Cross-Site Request Forgery) là tấn công lợi dụng trình duyệt tự đính kèm cookie khi gửi request. Kịch bản: user đang đăng nhập bank.com (session cookie JSESSIONID lưu ở browser), rồi ghé trang độc hại có form ẩn POST bank.com/transfer. Trình duyệt tự gửi kèm cookie → server nhận được request hợp lệ từ góc nhìn session, nhưng thực ra do kẻ tấn công kích hoạt.
Lỗ hổng CSRF tồn tại khi: (1) xác thực dựa vào cookie/session, VÀ (2) trình duyệt tự đính kèm credential đó. REST API dùng JWT trong header Authorization không thoả điều kiện này — trình duyệt không tự đính kèm header Authorization cross-site, chỉ có JavaScript mới làm được và Same-Origin Policy chặn cross-origin JavaScript.
Vì vậy REST API stateless + JWT không có CSRF surface → disable CSRF là đúng, không phải tắt bừa:
http.csrf(csrf -> csrf.disable());
Nếu app dùng form login + session cookie (chain web form login ở ví dụ trên), giữ CSRF bật. Thymeleaf template tự inject _csrf token vào form; SPA dùng CookieCsrfTokenRepository đọc từ cookie. Tắt CSRF chỉ an toàn khi xác thực không dùng cookie session.
5. WebSecurityCustomizer — bypass filter hoàn toàn cho static resources
Static resources (CSS, JS, images) không cần security check nào cả. Nếu đặt permitAll() trong chain, request vẫn chạy qua toàn bộ filter stack — tốn CPU không cần thiết và ghi log thừa. WebSecurityCustomizer.ignoring() bypass hẳn FilterChainProxy:
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring()
.requestMatchers(
"/static/**",
"/css/**",
"/js/**",
"/images/**",
"/favicon.ico",
"/robots.txt"
);
}
Luồng request khi có ignoring():
flowchart LR Req["GET /css/app.css"] DF["DelegatingFilterProxy<br/>(Spring Security entry)"] FCP["FilterChainProxy"] Skip["Bo qua toan bo filter chain<br/>khong log, khong auth check"] Servlet["DispatcherServlet<br/>(tra file tinh)"] Req --> DF DF -->|"URL nam trong ignoring()"| Skip Skip --> Servlet DF -.->|"URL khong ignore"| FCP
Quy tắc an toàn khi dùng ignoring():
- Dùng cho: CSS, JS, images, fonts, favicon — file public hoàn toàn, không chứa logic.
- Không dùng cho:
/api/health,/actuator/health— các endpoint này vẫn nên trong chain vớipermitAll()để có security headers và audit log. - Không dùng cho: bất kỳ path nào có thể trả dữ liệu nhạy cảm —
ignoring()tắt hết security, kể cả HTTPS redirect.
6. Sơ đồ tổng thể — ba chain trong cùng một app
flowchart TB Req["HTTP Request"] FCP["FilterChainProxy<br/>doc @Order"] IG["WebSecurityCustomizer.ignoring()<br/>/static/** /css/** /js/**<br/>BYPASS -- khong qua FCP"] C1["Chain @Order(1): adminChain<br/>securityMatcher /admin/**<br/>JWT + hasRole ADMIN"] C2["Chain @Order(2): apiChain<br/>securityMatcher /api/**<br/>JWT stateless + CSRF off"] C3["Chain @Order(3): defaultChain<br/>catch-all<br/>form login + session"] Req -->|"/static/app.css"| IG Req -->|"toan bo URL con lai"| FCP FCP --> C1 C1 -->|"no match"| C2 C2 -->|"no match"| C3
7. Pitfall
Pitfall 1 — quên securityMatcher() ở chain trung gian:
// SAI -- adminChain khong co securityMatcher -> nuot tat ca request
@Bean @Order(1)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
http
// .securityMatcher("/admin/**") <-- quen dong nay
.authorizeHttpRequests(auth -> auth
.anyRequest().hasRole("ADMIN")
);
return http.build();
}
@Bean @Order(2)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
// Chain nay KHONG BAO GIO duoc chon -- adminChain lay het
...
}
// DUNG -- moi chain (tru catch-all cuoi) phai co securityMatcher
@Bean @Order(1)
public SecurityFilterChain adminChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/admin/**") // scope ro rang
.authorizeHttpRequests(auth -> auth
.anyRequest().hasRole("ADMIN")
);
return http.build();
}
Pitfall 2 — set STATELESS nhưng quên disable CSRF, app throw 403:
// SAI -- STATELESS + CSRF con bat: Spring Security generate CSRF token
// nhung STATELESS -> session khong luu token -> moi request deu bi reject
http
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// .csrf(csrf -> csrf.disable()) <-- quen
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
// Ket qua: POST /api/orders -> 403 Forbidden (CSRF mismatch)
// DUNG -- STATELESS luon di kem voi csrf.disable() cho REST API
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
Pitfall 3 — @Order trùng nhau gây lỗi khởi động:
// SAI -- hai chain cung @Order(1) -> IllegalStateException khi start
@Bean @Order(1) public SecurityFilterChain adminChain(...) { ... }
@Bean @Order(1) public SecurityFilterChain apiChain(...) { ... }
Spring Security ném IllegalStateException: Found ambiguous @Order nếu hai chain trùng order. Mỗi chain phải có @Order riêng biệt.
8. Liên hệ các bài khác
Bài này chỉ là một mảnh trong kiến trúc bảo mật. Để thấy bức tranh đầy đủ:
- SecurityFilterChain DSL: cú pháp
HttpSecuritylambda đầy đủ —requestMatchers,exceptionHandling,headers; bài này dùng làm nền cho tất cả config ở trên. - JWT structure & validation:
oauth2ResourceServer.jwt(...)ở bài này validate token thế nào — signature algorithm,expclaim,JwtAuthenticationConvertermap claims thànhGrantedAuthority. - CORS & CSRF: bài này nói
csrf.disable()an toàn cho REST API; bài 06 đào sâu tại sao — cơ chế tấn công CSRF, điều kiện cần để exploit, và khi nào web app truyền thống cần CSRF bật. - Method Security:
@EnableMethodSecurity+@PreAuthorize("hasRole('ADMIN')")là tầng thứ hai ngoài chain config — bài 05 giải thích AOP proxy mechanism và khi nào cần defense-in-depth.
Tóm tắt
FilterChainProxyduyệtList<SecurityFilterChain>theo@Order(số nhỏ = ưu tiên cao), chọn chain đầu tiên cósecurityMatcherkhớp URL — một request chỉ qua một chain.securityMatcherlà cổng vào của chain (chain có nhận không);requestMatchersbên trong là authorization rules (được phép không).- Chain cuối không cần
securityMatcher— đóng vai catch-all. SessionCreationPolicy.STATELESSngăn Spring Security tạo/đọcHttpSession— mỗi JWT request tự lập, không cần session state, scale horizontally dễ dàng.csrf.disable()an toàn cho REST API JWT vì xác thực không dựa vào cookie — trình duyệt không tự đính kèmAuthorizationheader cross-site.WebSecurityCustomizer.ignoring()bypass hoàn toànFilterChainProxycho static resources — không log, không auth check, không security headers.
Tự kiểm tra
Q1App có 3 SecurityFilterChain với @Order(1), @Order(2), @Order(3). Chain @Order(1) không khai báo securityMatcher. Request GET /api/orders sẽ được xử lý bởi chain nào? Hệ quả là gì?▸
SecurityFilterChain với @Order(1), @Order(2), @Order(3). Chain @Order(1) không khai báo securityMatcher. Request GET /api/orders sẽ được xử lý bởi chain nào? Hệ quả là gì?Chain @Order(1) sẽ nhận request, dù URL là /api/orders. Lý do: khi chain không khai báo securityMatcher, FilterChainProxy coi nó là match tất cả URL. Đây là chain đầu tiên trong danh sách (order thấp nhất) → luôn được chọn cho mọi request.
Hệ quả: chain @Order(2) và @Order(3) không bao giờ được dùng. Mọi request đều chạy qua chain 1, dù chain đó không thiết kế cho URL đó.
Fix: chỉ chain cuối cùng (catch-all) mới được phép bỏ securityMatcher. Mọi chain trước đó phải khai báo securityMatcher rõ ràng để giới hạn phạm vi.
Q2Giải thích tại sao SessionCreationPolicy.STATELESS là điều kiện cần để scale horizontally REST API. Cơ chế bên dưới là gì?▸
SessionCreationPolicy.STATELESS là điều kiện cần để scale horizontally REST API. Cơ chế bên dưới là gì?Khi dùng session (chế độ mặc định IF_REQUIRED), Spring Security lưu SecurityContext vào HttpSession sau mỗi request xác thực. Session được lưu trong memory của pod cụ thể (hoặc Redis nếu cấu hình session replication). Request tiếp theo của user phải được route đến đúng pod đang giữ session — đây là "session affinity" hay "sticky session".
Khi đặt STATELESS, Spring Security không ghi và không đọc SecurityContext vào session. Mỗi request mang JWT trong Authorization header; BearerTokenAuthenticationFilter validate token và dựng SecurityContext ngay tại request đó — không cần bất kỳ state nào từ request trước. Sau khi response xong, SecurityContext bị garbage collected.
Hệ quả: bất kỳ pod nào trong cluster cũng xử lý được bất kỳ request nào, load balancer có thể phân tải tự do — không cần sticky session, không cần shared session store.
Q3Khi nào csrf.disable() an toàn, khi nào nguy hiểm? Giải thích theo cơ chế tấn công CSRF.▸
csrf.disable() an toàn, khi nào nguy hiểm? Giải thích theo cơ chế tấn công CSRF.CSRF exploit điều kiện: (1) server xác thực bằng cookie/session — trình duyệt tự đính kèm cookie khi gửi request đến domain đó; (2) kẻ tấn công tạo được request từ trang khác đến server nạn nhân (form ẩn, image tag, JavaScript).
An toàn để disable khi REST API dùng JWT trong header Authorization: Bearer <token>: trình duyệt không bao giờ tự đính kèm header này cross-site — chỉ JavaScript mới đặt được header, và Same-Origin Policy chặn cross-origin fetch. Không có cơ chế nào để trang độc hại gửi JWT hợp lệ thay người dùng → không có CSRF surface.
Nguy hiểm khi disable với web app dùng session cookie: kẻ tấn công tạo form ẩn POST đến server, trình duyệt tự đính kèm JSESSIONID cookie → server nhận request "hợp lệ" từ góc nhìn session. Trong trường hợp này, CSRF protection là bắt buộc.
Quy tắc nhớ: xác thực dùng cookie/session → giữ CSRF; xác thực dùng JWT header → disable CSRF an toàn.
Q4Sự khác biệt giữa WebSecurityCustomizer.ignoring() và permitAll() trong authorizeHttpRequests? Khi nào dùng cái nào?▸
WebSecurityCustomizer.ignoring() và permitAll() trong authorizeHttpRequests? Khi nào dùng cái nào?permitAll(): request vẫn đi qua toàn bộ filter stack của FilterChainProxy — được authenticate (nếu có token), được log, nhận security headers (HSTS, CSP), nhưng authorization rule cho phép qua kể cả anonymous user.
WebSecurityCustomizer.ignoring(): request được DelegatingFilterProxy tách ra trước khi vào FilterChainProxy — không qua bất kỳ filter nào, không log security, không security headers, không authenticate.
Dùng ignoring() cho: CSS, JS, images, fonts, favicon — file static hoàn toàn public, không bao giờ chứa dữ liệu nhạy cảm, muốn tối ưu performance.
Dùng permitAll() cho: /actuator/health, /api/auth/**, public API endpoint — vẫn muốn security headers, audit log, hoặc có thể cần đọc identity (dù không bắt buộc).
Q5App có chain @Order(1) với securityMatcher("/api/**") và chain @Order(2) catch-all. Request POST /api/auth/login bị 403 dù đã cấu hình permitAll() cho /api/auth/** trong chain 2. Giải thích bug và cách fix.▸
@Order(1) với securityMatcher("/api/**") và chain @Order(2) catch-all. Request POST /api/auth/login bị 403 dù đã cấu hình permitAll() cho /api/auth/** trong chain 2. Giải thích bug và cách fix.Bug: /api/auth/login khớp securityMatcher("/api/**") nên chain @Order(1) nhận request. Chain 2 không bao giờ được xét. permitAll() cho /api/auth/** nằm trong chain 2 → hoàn toàn vô hiệu với request này.
Chain @Order(1) có authorization rule riêng của nó (ví dụ .anyRequest().authenticated()) và áp dụng cho /api/auth/login → 403 khi không có token.
Fix: chuyển rule permitAll() cho /api/auth/** vào đúng chain đang nhận URL đó — tức chain @Order(1):
Trong chain @Order(1), thêm: requestMatchers("/api/auth/**").permitAll() trước .anyRequest().authenticated(). Authorization rule phải nằm trong đúng chain xử lý URL đó, không phải chain khác.
Bài tiếp theo: Tổng kết module
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên