Authentication flow & SecurityContext — từ request đến principal trong ThreadLocal
Bóc đúng một mảnh của Spring Security: request đi qua filter, AuthenticationManager điều phối AuthenticationProvider để trả Authentication hoặc ném exception; kết quả lưu vào SecurityContextHolder ThreadLocal per-request. Giải thích tại sao ThreadLocal, tại sao tách authentication/authorization, và cơ chế exception mapping 401/403.
TL;DR: Khi request mang Authorization: Bearer <token> vào Spring Security, BearerTokenAuthenticationFilter trích token, xây object Authentication chưa xác thực rồi giao cho AuthenticationManager.authenticate(). ProviderManager lần lượt hỏi từng AuthenticationProvider — provider nào khai báo supports() đúng loại token sẽ thực sự validate. Kết quả trả về là Authentication đã xác thực, được lưu vào SecurityContextHolder bằng ThreadLocal — mỗi thread (mỗi request) giữ context riêng, không lẫn. Cuối chain, AuthorizationFilter đọc context đó để quyết định cho qua hay ném AccessDeniedException. ExceptionTranslationFilter map: AuthenticationException → 401, AccessDeniedException → 403.
Bài 01 — Filter chain architecture đã bóc cấu trúc ba tầng DelegatingFilterProxy → FilterChainProxy → SecurityFilterChain. Bài này đào sâu vào cơ chế bên trong một lần xử lý request: authentication flow, SecurityContext, và exception path.
1. Scenario — request JWT vào API
TaskFlow v2 có endpoint GET /api/projects. Request từ frontend mang header:
Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJhbGljZUBleGFtcGxlLmNvbSIsInJvbGVzIjpbIlJPTEVfVVNFUiJdfQ.sig
Chuỗi câu hỏi cần trả lời:
- Filter nào trong chain chịu trách nhiệm đọc header đó?
- Token được validate ở đâu, bằng cơ chế gì?
- "Alice đã login" được lưu ở đâu để controller sau đó đọc được?
- Nếu token hết hạn, response 401 được sinh ra ở đâu trong chain?
Bốn câu hỏi đó chính xác là bốn thứ bài này giải thích.
2. Authentication flow — cơ chế bên dưới
Đường đi của một request JWT, kèm hai chỗ nó có thể rơi ra ngoài:

Ba actor quan trọng là AuthenticationManager, AuthenticationProvider, và SecurityContextHolder — mỗi actor có trách nhiệm tách biệt. SecurityContextHolder ở giữa hình chính là chỗ nối: nửa trên ghi danh tính vào đó, nửa dưới đọc ra để phân quyền. Hai lối thoát 401 và 403 sẽ được bóc kỹ ở mục 8.
3. Authentication object — hai trạng thái
Authentication (xác thực) là interface trung tâm, đại diện danh tính của một request. Nó tồn tại ở hai trạng thái khác nhau trong một lần xử lý:
public interface Authentication {
String getName();
Object getPrincipal(); // user object (UserDetails, JWT subject)
Object getCredentials(); // password / token — cleared sau auth
Collection<? extends GrantedAuthority> getAuthorities();
boolean isAuthenticated();
Object getDetails(); // request metadata (IP, session id)
}
| Trạng thái | isAuthenticated() | getPrincipal() | getCredentials() | getAuthorities() |
|---|---|---|---|---|
| Trước xác thực (input) | false | username / raw token | password / token raw | rỗng |
| Sau xác thực (output) | true | UserDetails object | null (đã xoá) | roles / permissions đầy đủ |
Chuyển trạng thái chỉ xảy ra qua đúng một cửa — provider.authenticate():

Lý do credentials bị xoá sau khi xác thực thành công: giảm rủi ro password hoặc raw token nằm trong heap quá lâu. Nếu memory dump bị lấy, token không còn ở đó. ProviderManager mặc định bật eraseCredentialsAfterAuthentication = true.
4. AuthenticationManager — entry point duy nhất
AuthenticationManager là interface entry point cho mọi yêu cầu xác thực trong Spring Security:
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
}
Interface chỉ có một method duy nhất. Filter gọi manager.authenticate(unauthenticated) rồi nhận lại Authentication đã xác thực, hoặc bắt exception nếu thất bại.
Implementation mặc định là ProviderManager — bên trong nó giữ một danh sách AuthenticationProvider và thử lần lượt:
// Simplified logic in ProviderManager.authenticate()
for (AuthenticationProvider provider : this.providers) {
if (!provider.supports(authentication.getClass())) {
continue; // skip — not this provider's job
}
try {
result = provider.authenticate(authentication);
if (result != null) {
copyDetails(authentication, result);
break;
}
} catch (AccountStatusException | InternalAuthenticationServiceException ex) {
throw ex; // non-recoverable — stop immediately
} catch (AuthenticationException ex) {
lastException = ex; // try next provider
}
}
if (result == null) throw lastException;
Điểm quan trọng: ProviderManager không biết gì về JWT hay username/password — nó chỉ điều phối. Kiến thức về từng loại token nằm trong AuthenticationProvider. Provider đầu tiên vừa supports() vừa authenticate thành công sẽ thắng — các provider sau không được hỏi:

5. AuthenticationProvider — pluggable strategy
AuthenticationProvider là strategy cho từng loại token. Mỗi provider khai báo supports() để nói "tôi handle loại token nào":
public interface AuthenticationProvider {
Authentication authenticate(Authentication authentication)
throws AuthenticationException;
boolean supports(Class<?> authentication);
}
Bảng provider phổ biến trong Spring Security:
| Provider | Token type | Dùng khi |
|---|---|---|
DaoAuthenticationProvider | UsernamePasswordAuthenticationToken | Form login, Basic Auth |
JwtAuthenticationProvider | BearerTokenAuthenticationToken | JWT Bearer token |
Saml2AuthenticationProvider | Saml2AuthenticationToken | SAML SSO |
| Custom | Custom token | OAuth2 client credential, API key |
Tách authentication thành nhiều provider độc lập cho phép app hỗ trợ đồng thời nhiều phương thức xác thực — cùng một chain có thể chấp nhận cả form login và JWT Bearer mà không có code rẽ nhánh if/else.
5.1 DaoAuthenticationProvider và UserDetailsService
Khi người dùng submit form login (username + password), DaoAuthenticationProvider cần tra cứu user từ database. Nó làm việc này qua UserDetailsService — interface một method:
public interface UserDetailsService {
UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}
App triển khai UserDetailsService để kết nối với DB của mình. Xem chi tiết tại UserDetailsService & BCrypt.
6. SecurityContext + ThreadLocal — tại sao mỗi request có context riêng
Sau khi provider trả về Authentication đã xác thực, filter lưu nó vào SecurityContextHolder:
// In BearerTokenAuthenticationFilter (simplified)
Authentication authResult = authManager.authenticate(token);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authResult);
SecurityContextHolder.setContext(context);
chain.doFilter(request, response); // pass to next filter / controller
SecurityContextHolder.clearContext(); // cleanup — CRITICAL
SecurityContextHolder dùng ThreadLocal làm storage:
public class ThreadLocalSecurityContextHolderStrategy
implements SecurityContextHolderStrategy {
private static final ThreadLocal<Supplier<SecurityContext>> contextHolder =
new ThreadLocal<>();
@Override
public SecurityContext getContext() {
return getDeferredContext().get();
}
@Override
public void clearContext() {
contextHolder.remove(); // remove, not set null
}
}
6.1 Tại sao ThreadLocal?
Câu hỏi cốt lõi: tại sao Spring Security chọn ThreadLocal thay vì truyền Authentication qua method parameter từ filter xuống controller?
Lý do kiến trúc:
- Không sửa API hiện có: Servlet
Filter.doFilter()vàHttpServlet.service()có signature cố định — không thể thêm parameterAuthenticationvào mà không phá vỡ toàn bộ Servlet API.ThreadLocallà cách inject context mà không đụng đến signature. - Mỗi request chạy trên một thread riêng (mô hình thread-per-request của Tomcat): mỗi thread có vùng
ThreadLocalđộc lập → context của request Alice không bao giờ lẫn sang request Bob dù cùng chạy song song. - Transparent cho business code: controller không cần nhận
Authenticationqua parameter — framework tự inject qua@AuthenticationPrincipalhoặcSecurityContextHolder.getContext().
Hình dung:
Thread-1 (Alice's request) Thread-2 (Bob's request)
ThreadLocal["context"] = Alice ThreadLocal["context"] = Bob
... ...
getContext() → Alice getContext() → Bob ← riêng biệt
clearContext() clearContext()
6.2 Cleanup bắt buộc
clearContext() được gọi trong finally block sau khi chain hoàn thành. Nếu không clear, thread trả về pool vẫn còn SecurityContext cũ — request tiếp theo lấy thread đó ra sẽ đọc context của request trước. Đây là lỗ hổng bảo mật nghiêm trọng.
// Pattern đúng — cleanup trong finally
try {
SecurityContextHolder.setContext(context);
chain.doFilter(request, response);
} finally {
SecurityContextHolder.clearContext(); // ALWAYS, even if exception
}
6.3 Pitfall — ThreadLocal không propagate sang thread khác
ThreadLocal chỉ có hiệu lực trong thread đang chạy. Khi dùng @Async hoặc CompletableFuture, code chạy trên thread pool khác — SecurityContext biến mất:
// SAI — context null trong async thread
@Async
public void processOrder() {
// SecurityContextHolder.getContext().getAuthentication() == null ← NPE
}
// DUNG — capture truoc khi switch thread
@PostMapping("/orders")
public CompletableFuture<Void> placeOrder(
@AuthenticationPrincipal UserDetails user,
@RequestBody OrderRequest req
) {
String username = user.getUsername(); // capture tren request thread
return CompletableFuture.runAsync(() -> orderService.process(username, req));
}
Bài SecurityFilterChain DSL sẽ demo cách cấu hình DelegatingSecurityContextExecutor cho use case async cần truyền context.
7. Authorization — sau khi có Authentication
AuthorizationFilter là filter cuối cùng trong chain. Sau khi Authentication đã được lưu vào SecurityContextHolder, filter này đọc ra và kiểm tra:
// Simplified logic in AuthorizationFilter
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
AuthorizationDecision decision = authorizationManager.check(() -> authentication, request);
if (!decision.isGranted()) {
throw new AccessDeniedException("Access Denied");
}
// else: call chain.doFilter() — pass through
AuthorizationManager được config trong SecurityFilterChain:
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/projects/**").authenticated()
.anyRequest().permitAll()
);
Tại sao tách authentication và authorization thành hai bước riêng?
Nếu gộp lại, một component vừa hỏi "bạn là ai?" vừa hỏi "bạn được làm gì?" — hai concern khác nhau, khó mở rộng độc lập. Tách rời cho phép:
- Thay đổi cơ chế xác thực (từ password sang JWT, sang SAML) mà không động vào authorization rule.
- Thêm authorization rule mới (từ RBAC sang ABAC) mà không cần biết token được validate ra sao.
- Hai filter chạy ở hai vị trí khác nhau trong chain — filter trước cần reject sớm nếu token không hợp lệ, không cần chờ đến authorization.
8. Exception flow — AuthenticationEntryPoint vs AccessDeniedHandler
ExceptionTranslationFilter nằm ngay trước AuthorizationFilter trong chain. Nhiệm vụ của nó là bắt hai loại exception và map sang HTTP response tương ứng — đúng hai lối thoát đã vẽ ở sơ đồ mục 2. Vị trí trên đường đi quyết định mã trả về: rơi ra trước khi danh tính kịp vào SecurityContextHolder thì là 401, rơi ra sau đó thì là 403.
Hai handler chịu trách nhiệm sinh HTTP response:
| Exception | Handler | HTTP Status | Khi nào |
|---|---|---|---|
AuthenticationException | AuthenticationEntryPoint | 401 Unauthorized | Token không hợp lệ, hết hạn, thiếu |
AccessDeniedException | AccessDeniedHandler | 403 Forbidden | Token hợp lệ nhưng không có quyền |
Với REST API dùng JWT, cần config handler trả JSON thay vì redirect về form login:
http.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, authException) -> {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Unauthorized\"}");
})
.accessDeniedHandler((request, response, accessDeniedException) -> {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json");
response.getWriter().write("{\"error\":\"Forbidden\"}");
})
);
Nếu không config, Spring Security mặc định redirect đến /login khi gặp 401 — đúng cho web app form login, nhưng sai cho REST API (frontend nhận 302, không phải 401).
8.1 Anonymous user
Khi request không có token, AnonymousAuthenticationFilter tự động đặt vào SecurityContextHolder một AnonymousAuthenticationToken với authority ROLE_ANONYMOUS. Điều này giúp AuthorizationFilter xử lý nhất quán — luôn có Authentication trong context, không bao giờ null. Khi anonymous user truy cập endpoint protected, AuthorizationFilter ném AccessDeniedException; ExceptionTranslationFilter phát hiện đây là anonymous nên gọi AuthenticationEntryPoint (401) thay vì AccessDeniedHandler (403).
9. Cơ chế bên dưới — luồng đầy đủ một request JWT
Gộp tất cả lại, đây là trình tự chính xác từ byte đầu của request cho đến controller:
1. Tomcat nhận HTTP request, dispatch sang thread từ pool
2. DelegatingFilterProxy.doFilter() → lazy-fetch FilterChainProxy bean
3. FilterChainProxy match URL → chọn SecurityFilterChain
4. SecurityContextHolderFilter: load SecurityContext từ session hoặc tạo mới → lưu ThreadLocal
5. CsrfFilter: check CSRF token (nếu enabled)
6. BearerTokenAuthenticationFilter:
a. Đọc header Authorization: Bearer <token>
b. Tạo BearerTokenAuthenticationToken(token) — isAuthenticated() = false
c. Gọi AuthenticationManager.authenticate(token)
d. ProviderManager iterate providers:
- JwtAuthenticationProvider.supports(BearerTokenAuthenticationToken) = true
- JwtAuthenticationProvider.authenticate(): decode JWT, verify signature, check expiry
- Trả về JwtAuthenticationToken — isAuthenticated() = true, authorities = [ROLE_USER]
e. SecurityContextHolder.setContext(context với JwtAuthenticationToken)
f. chain.doFilter() — tiếp tục
7. AnonymousAuthenticationFilter: context đã có auth → skip
8. ExceptionTranslationFilter: wrap chain trong try/catch
9. AuthorizationFilter:
a. Đọc Authentication từ SecurityContextHolder
b. AuthorizationManager.check(): Alice có ROLE_USER, endpoint yêu cầu authenticated() → granted
c. chain.doFilter() — tiếp tục
10. DispatcherServlet → Controller nhận request
11. Controller có thể đọc auth qua @AuthenticationPrincipal hoặc SecurityContextHolder
12. Response trả về, SecurityContextHolder.clearContext() trong finally
10. Pitfall
Nhầm 1 — Không config AuthenticationEntryPoint cho REST API:
// SAI — default Spring Security redirect 302 /login khi token expired
// Frontend nhận 302 Not Modified thay vi 401 Unauthorized
// DUNG — override handler tra JSON
http.exceptionHandling(ex -> ex
.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint())
.accessDeniedHandler(new BearerTokenAccessDeniedHandler())
);
// BearerTokenAuthenticationEntryPoint tra 401 + WWW-Authenticate header
// BearerTokenAccessDeniedHandler tra 403 + WWW-Authenticate header
Nhầm 2 — Tưởng SecurityContextHolder.getContext() trả null khi không có user:
// SAI assumption
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) { ... } // dung — auth co the null truoc khi set
// DUNG — kiem tra ca null va isAuthenticated
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated()
&& !(auth instanceof AnonymousAuthenticationToken)) {
// da xac thuc that su
}
Nhầm 3 — Gọi SecurityContextHolder trong constructor của Spring bean:
Bean được khởi tạo lúc application startup — trước khi bất kỳ request nào đến. SecurityContextHolder chưa có context lúc đó. Đọc SecurityContext trong constructor luôn trả null.
Liên hệ các bài khác
- 01 — Filter chain architecture: bài này đã mô tả cấu trúc ba tầng
DelegatingFilterProxy→FilterChainProxy→SecurityFilterChainvà danh sách 15+ filter trong chain — nền để hiểu tại saoBearerTokenAuthenticationFiltervàExceptionTranslationFilternằm ở vị trí đó. - UserDetailsService & BCrypt:
DaoAuthenticationProvider(form login) dùngUserDetailsServiceđể tra cứu user từ DB vàPasswordEncoderđể verify password hash — phần bổ sung cho authentication flow bài này khi dùng username/password thay JWT. - 03 — SecurityFilterChain DSL: DSL
http.authorizeHttpRequests(),http.oauth2ResourceServer(), vàhttp.exceptionHandling()là cách khai báo config cho filter chain và exception handler đã đề cập ở đây.
Tóm tắt
BearerTokenAuthenticationFilterđọc header, xâyAuthenticationchưa xác thực (input), giao choAuthenticationManager.ProviderManagerđiều phối danh sáchAuthenticationProvider— mỗi provider handle một loại token quasupports().- Provider trả về
Authenticationđã xác thực (output):isAuthenticated() = true, credentials =null, authorities đầy đủ. SecurityContextHolderlưuAuthenticationvàoThreadLocalper-thread — mỗi request có context riêng, không lẫn nhau;clearContext()trongfinallylà bắt buộc.ThreadLocalđược chọn vì Servlet API không cho phép truyềnAuthenticationqua parameter; thread-per-request của Tomcat đảm bảo isolation.- Authentication và authorization tách rời: filter authenticate trước,
AuthorizationFiltercheck authority sau — hai concern độc lập, dễ mở rộng riêng. ExceptionTranslationFiltermap:AuthenticationException→ 401 (AuthenticationEntryPoint),AccessDeniedException→ 403 (AccessDeniedHandler). REST API cần override cả hai để trả JSON thay redirect.
Tự kiểm tra
- Q1Tại sao
ProviderManagergiữ danh sáchAuthenticationProviderthay vì một provider duy nhất? Lợi ích thiết kế là gì? - Q2Giải thích tại sao Spring Security dùng
ThreadLocalđể lưuSecurityContextthay vì truyềnAuthenticationqua method parameter từ filter xuống controller. - Q3Phân biệt
AuthenticationExceptionvàAccessDeniedException. Mỗi loại exception được xử lý bởi component nào và sinh ra HTTP status code gì? - Q4Tại sao authentication và authorization được thiết kế thành hai bước riêng biệt trong Spring Security chain, thay vì gộp vào một component?
- Q5Đoạn code sau có lỗi bảo mật tiềm ẩn liên quan đến
ThreadLocal. Giải thích lỗi và cách sửa.SecurityContextHolder.setContext(context);chain.doFilter(request, response);// no clearContext()
Bài tiếp theo: SecurityFilterChain DSL
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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