spring security
Tung Bui Thanh (TungDaDev)13 mins read

Spring Security hay bị gọi là "hộp đen" toàn phép thuật ngầm, mới đụng vào là thấy ngại. Nhưng làm backend cỡ lớn thì chỉ biết copy-paste cấu hình là không đủ. Ở đây mình gỡ bớt mấy khái niệm rườm rà, nói rõ Spring Security thật ra chạy thế nào, rồi cấu hình bảo mật sao cho gọn mà vẫn đủ chắc để chạy production.
# bản chất
Đừng để mấy cái annotation đánh lừa. Bóc ra thì Spring Security chạy dựa trên một pattern kinh điển của Servlet: Filter Chain.
Mọi HTTP request trước khi tới được Controller đều phải đi qua một "trạm kiểm soát" gồm nhiều lớp filter. Trạm này trả lời hai câu hỏi:
- Authentication: Anh là ai? (Xác thực danh tính)
- Authorization: Anh có quyền làm việc này không? (Cấp quyền truy cập)
# dòng chảy của một request

Đường đi của một request qua các tầng bảo mật trông như sau. DelegatingFilterProxy là một Servlet filter bình thường, đăng ký với Tomcat; nó chỉ chuyển request cho bean FilterChainProxy trong Spring context. FilterChainProxy chọn một SecurityFilterChain khớp đầu tiên với request rồi cho request đi qua lần lượt các filter của chain đó:
HTTP Request
|
v
DelegatingFilterProxy
|
v
FilterChainProxy
|
v
SecurityFilterChain (ordered filters)
+-- DisableEncodeUrlFilter
+-- SecurityContextHolderFilter
+-- CsrfFilter
+-- LogoutFilter
+-- OAuth2AuthorizationRequestRedirectFilter
+-- BearerTokenAuthenticationFilter <- JWT validation
+-- RequestCacheAwareFilter
+-- AnonymousAuthenticationFilter
+-- ExceptionTranslationFilter
+-- AuthorizationFilter <- access control
|
v
Controller (if authorized)Danh sách trên là bản rút gọn để minh hoạ. Filter nào có mặt phụ thuộc vào cấu hình: tắt CSRF thì không có CsrfFilter, OAuth2AuthorizationRequestRedirectFilter chỉ có khi dùng oauth2Login() (app đóng vai OAuth2 client), còn resource server dùng JWT thì chỉ cần BearerTokenAuthenticationFilter. Muốn biết chính xác app của bạn có những filter nào, theo thứ tự nào, bật log logging.level.org.springframework.security=DEBUG, lúc khởi động Spring Security sẽ in ra danh sách filter của từng chain.
# core components
Muốn điều khiển được Spring Security thì phải thuộc mấy bánh răng tạo nên bộ máy này:
| Component | Vai trò |
|---|---|
| SecurityFilterChain | Chuỗi filters xử lý security |
| AuthenticationManager | Orchestrate authentication |
| AuthenticationProvider | Thực hiện authentication logic |
| UserDetailsService | Load user data từ DB/LDAP |
| SecurityContext | Lưu Authentication object (current user) |
| GrantedAuthority | Quyền/role của user |
# SecurityFilterChain configuration (spring boot 3)
Từ Spring Boot 3 (Spring Security 6), WebSecurityConfigurerAdapter bị xoá hẳn, cấu hình bằng cách khai báo bean SecurityFilterChain. Cách viết dùng lambda (Lambda DSL) là cách được khuyến nghị: kiểu cũ nối bằng .and() bị deprecate từ 6.1 và bỏ hẳn ở Spring Security 7. Đây là bộ khung cấu hình cho một RESTful API kiểu stateless:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// Tắt CSRF vì REST API Stateless không dùng Cookie/Session
.csrf(csrf -> csrf.disable())
// Cấu hình CORS chặt chẽ từ một source tách biệt
.cors(cors -> cors.configurationSource(corsConfigSource()))
// Ép hệ thống chạy ở chế độ Stateless (Không sinh JSESSIONID)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// Định tuyến quyền truy cập từng endpoint
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/api/public/**", "/error").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().denyAll() // Nguyên tắc Default Deny
)
// Tích hợp OAuth2 Resource Server cho JWT
// JwtDecoder do Spring Boot tự tạo từ cấu hình spring.security.oauth2.resourceserver.jwt.*
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
// Xử lý ngoại lệ thanh lịch
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
.accessDeniedHandler((req, res, e) -> res.setStatus(HttpServletResponse.SC_FORBIDDEN))
);
return http.build();
}
}Nguyên tắc quan trọng nhất khi cấu hình security là "Default Deny" (mặc định từ chối). Luôn để anyRequest().denyAll() ở cuối, để endpoint mới nào lỡ quên cấu hình cũng không bị mở toang. Các rule được xét từ trên xuống, rule nào khớp trước thì dùng rule đó, nên rule cụ thể phải đứng trước rule chung.
Có một cái bẫy đi kèm denyAll: khi controller ném exception, Tomcat forward request sang /error để Spring Boot dựng error response, và từ Spring Security 6 lượt forward này cũng bị kiểm tra quyền. Không mở /error thì nó rơi vào denyAll(), client nhận 401/403 thay vì 500 thật, debug rất dễ đi lạc. Vì vậy cấu hình trên có thêm "/error" vào permitAll().
corsConfigSource() là bean khai báo ở mục CORS bên dưới.
# oauth2 resource server (jwt)
Trong hệ thống phân tán hay microservices, giao việc xác thực cho một Identity Provider (như Keycloak) gần như là mặc định.
# config
Khai báo tĩnh trong application.yml:
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://keycloak:8080/realms/tungdadev-realm
# Jwks-uri giúp load public key động để verify token signature
jwk-set-uri: http://keycloak:8080/realms/tungdadev-realm/protocol/openid-connect/certs# custom jwt → authentication mapping
Mặc định Spring đọc quyền từ claim scope/scp và gắn tiền tố SCOPE_. Mà mỗi Identity Provider lại có cấu trúc JWT claims riêng (ví dụ Keycloak nhét roles vào realm_access.roles, một claim lồng trong claim khác). Cần một "bộ phiên dịch" để Spring hiểu được:
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
// Spring Security 6.4+: đọc claim lồng nhau bằng biểu thức SpEL
ExpressionJwtGrantedAuthoritiesConverter authoritiesConverter = new ExpressionJwtGrantedAuthoritiesConverter(
new SpelExpressionParser().parseRaw("[realm_access][roles]")); // Keycloak format
authoritiesConverter.setAuthorityPrefix("ROLE_");
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter);
return converter;
}
// Hoặc custom converter cho Keycloak nested roles
@Bean
public Converter<Jwt, AbstractAuthenticationToken> keycloakJwtConverter() {
return jwt -> {
Collection<GrantedAuthority> authorities = extractKeycloakRoles(jwt);
return new JwtAuthenticationToken(jwt, authorities, jwt.getClaimAsString("preferred_username"));
};
}
@SuppressWarnings("unchecked")
private Collection<GrantedAuthority> extractKeycloakRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null || !(realmAccess.get("roles") instanceof List<?>)) return Collections.emptyList();
List<String> roles = (List<String>) realmAccess.get("roles");
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.collect(Collectors.toList());
}Lưu ý JwtGrantedAuthoritiesConverter (class quen thuộc trong nhiều bài hướng dẫn) không hiểu đường dẫn lồng nhau: setAuthoritiesClaimName("realm_access.roles") sẽ đi tìm một claim có tên đúng là "realm_access.roles", không thấy, và trả về danh sách quyền rỗng mà không báo lỗi gì. Mọi hasRole(...) sau đó đều trượt. Trước 6.4 thì phải viết converter tay như cách thứ hai.
Bean kiểu JwtAuthenticationConverter được Spring Security tự nhặt cho oauth2ResourceServer().jwt(). Còn bean kiểu Converter<Jwt, AbstractAuthenticationToken> như cách thứ hai thì phải gắn tay: .jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakJwtConverter())). Thêm nữa, role so khớp phân biệt hoa thường: cách thứ nhất giữ nguyên tên role của Keycloak (admin thành ROLE_admin), cách thứ hai đổi sang chữ hoa (ROLE_ADMIN), viết hasRole(...) phải theo đúng kiểu mình chọn.
# method security
Đẩy logic phân quyền xuống tận service layer thì code dùng lại được nhiều hơn, controller cũng khỏi phình to. Spring Security có SpEL (Spring Expression Language) dùng cho việc này rất tiện. Cần @EnableMethodSecurity (đã có trong SecurityConfig ở trên), và vì cơ chế này chạy bằng AOP proxy nên method gọi nội bộ trong cùng class sẽ không bị kiểm tra.
@Service
public class OrderService {
// Chỉ ADMIN mới được xóa
@PreAuthorize("hasRole('ADMIN')")
public void deleteOrder(String orderId) { ... }
// User chỉ được lấy danh sách đơn hàng của chính mình
@PreAuthorize("hasRole('USER') and #userId == authentication.name")
public List<Order> getOrders(String userId) { ... }
// Gọi đến một permission evaluator bean động
@PreAuthorize("@permissionEvaluator.hasWorkspaceAccess(#workspaceId, 'READ')")
public Workspace getWorkspace(String workspaceId) { ... }
// Kiểm tra sau khi chạy: lấy order xong mới check xem có phải của mình không (không hợp lệ thì ném AccessDeniedException)
@PostAuthorize("returnObject.ownerId == authentication.name or hasRole('ADMIN')")
public Order getOrder(String orderId) { ... }
}#userId lấy theo tên tham số, nên code phải compile với cờ -parameters (Spring Boot đã bật sẵn). Với @PostAuthorize, method vẫn chạy xong rồi mới bị chặn, nên đừng dùng nó cho method có tác dụng phụ (ghi DB, gửi mail). Còn muốn lọc bớt phần tử trong danh sách trả về thì dùng @PostFilter.
# custom authentication filter
public class ApiKeyAuthFilter extends OncePerRequestFilter {
private static final String API_KEY_HEADER = "X-API-Key";
private final ApiKeyService apiKeyService;
public ApiKeyAuthFilter(ApiKeyService apiKeyService) {
this.apiKeyService = apiKeyService;
}
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String apiKey = request.getHeader(API_KEY_HEADER);
if (apiKey != null && apiKeyService.isValid(apiKey)) {
// ApiKeyAuthentication: class tự viết, kế thừa AbstractAuthenticationToken và gọi setAuthenticated(true)
ApiKeyAuthentication auth = new ApiKeyAuthentication(apiKey, apiKeyService.getAuthorities(apiKey));
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
}
chain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/external/");
}
}
// Register filter
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, ApiKeyService apiKeyService) throws Exception {
http.addFilterBefore(new ApiKeyAuthFilter(apiKeyService), BearerTokenAuthenticationFilter.class);
return http.build();
}Vài điểm trong filter này. Tạo SecurityContext mới bằng createEmptyContext() thay vì sửa context đang có, đây là cách tài liệu Spring Security khuyên, để tránh race condition khi nhiều thread cùng tham chiếu một context. Filter được tạo bằng new chứ không đánh dấu @Component: nếu là bean, Spring Boot sẽ tự đăng ký nó thêm một lần nữa vào chuỗi filter của Servlet container, và nó chạy hai lần (một lần ngoài, một lần trong security chain). Key sai thì filter không chặn mà để request đi tiếp, AuthorizationFilter phía sau sẽ trả 401 vì request vẫn chưa được xác thực.
# cors configuration
@Bean
public CorsConfigurationSource corsConfigSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("http://localhost:3000", "https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}setAllowCredentials(true) chỉ cần khi trình duyệt phải gửi kèm cookie hoặc HTTP auth. API stateless dùng JWT trong header Authorization thì không cần bật. Khi đã bật thì không được dùng "*" cho allowed origins, Spring sẽ báo lỗi lúc xử lý request (muốn dùng wildcard thì phải chuyển sang setAllowedOriginPatterns).
# SecurityContext: lấy current user
// Trong Controller/Service
public String getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
return null; // chưa xác thực (vd code chạy ngoài HTTP request)
}
if (auth instanceof JwtAuthenticationToken jwtAuth) {
return jwtAuth.getToken().getClaimAsString("sub");
}
return auth.getName();
}
// Inject trực tiếp trong Controller
@GetMapping("/me")
public UserDTO getCurrentUser(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
String email = jwt.getClaimAsString("email");
List<String> roles = jwt.getClaimAsStringList("roles");
return userService.getUser(userId);
}# multiple SecurityFilterChains
Hệ thống thật thì hiếm khi chỉ có một kiểu bảo mật. Ví dụ: API cho mobile app dùng JWT, API cho đối tác bên ngoài dùng API key, còn Actuator thì dùng Basic Auth.
Cách làm là tạo nhiều filter chain với @Order khác nhau:
@Configuration
public class MultiSecurityConfig {
// Chain 1: API endpoints (JWT)
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
return http.build();
}
// Chain 2: Actuator endpoints (Basic Auth)
@Bean
@Order(2)
public SecurityFilterChain actuatorFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/actuator/**")
.httpBasic(Customizer.withDefaults())
.authorizeHttpRequests(auth -> auth.anyRequest().hasRole("ACTUATOR"));
return http.build();
}
// Chain 3: Public (no auth)
@Bean
@Order(3)
public SecurityFilterChain publicFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/public/**", "/health")
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll());
return http.build();
}
// Chain 4: mọi request còn lại (không có securityMatcher = khớp tất cả)
@Bean
@Order(4)
public SecurityFilterChain defaultDenyFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().denyAll());
return http.build();
}
}FilterChainProxy thử các chain theo thứ tự @Order, chain đầu tiên khớp sẽ xử lý request. Chain 4 rất quan trọng: nếu chỉ có ba chain đầu, request tới một đường dẫn không khớp chain nào (ví dụ /internal/debug) sẽ không đi qua filter bảo mật nào cả và tới thẳng controller. Chain không có securityMatcher khớp với mọi request, nên nó phải nằm cuối cùng; đặt nó lên trước thì các chain sau không bao giờ tới lượt, và Spring Security sẽ báo lỗi ngay lúc khởi động.
# exception handling
Trả nguyên stack trace Java về cho client là lộ hết ruột gan hệ thống. Format lại error response cho đàng hoàng:
@Component
public class CustomAuthEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setContentType("application/json;charset=UTF-8"); // thiếu charset thì tiếng Việt bị vỡ (mặc định ISO-8859-1)
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
// Có thể dùng ObjectMapper để serialize một ErrorResponse object tĩnh
response.getWriter().write("""
{
"status": 401,
"error": "UNAUTHORIZED",
"message": "Token không hợp lệ hoặc đã hết hạn"
}
""");
}
}
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write("""
{"error": "FORBIDDEN", "message": "Insufficient permissions"}
""");
}
}Hai class này khai báo @Component rồi thì nhớ gắn vào SecurityConfig thay cho handler viết inline ở trên: .exceptionHandling(ex -> ex.authenticationEntryPoint(customAuthEntryPoint).accessDeniedHandler(customAccessDeniedHandler)). Với resource server, lỗi token (hết hạn, sai chữ ký) đi qua entry point của oauth2ResourceServer, nên đặt luôn ở đó: .oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()).authenticationEntryPoint(customAuthEntryPoint)).
# testing
@WebMvcTest(OrderController.class)
@Import({SecurityConfig.class, OrderService.class}) // @WebMvcTest không tự nạp @Configuration và @Service
class OrderControllerSecurityTest {
@Autowired
MockMvc mockMvc;
@Test
@WithMockUser(roles = "ADMIN")
void adminCanDeleteOrder() throws Exception {
mockMvc.perform(delete("/api/orders/123"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void userCannotDeleteOrder() throws Exception {
mockMvc.perform(delete("/api/orders/123"))
.andExpect(status().isForbidden());
}
@Test
void unauthenticatedGetsDenied() throws Exception {
mockMvc.perform(get("/api/orders"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "user-1")
void userCanOnlyAccessOwnOrders() throws Exception {
mockMvc.perform(get("/api/users/user-1/orders"))
.andExpect(status().isOk());
mockMvc.perform(get("/api/users/user-2/orders"))
.andExpect(status().isForbidden());
}
}@WebMvcTest chỉ dựng tầng web: controller, filter, @ControllerAdvice... Class @Configuration như SecurityConfig không được nạp, nên phải @Import vào, không thì test chạy với cấu hình security mặc định của Spring Boot (bật CSRF, request DELETE bị 403 dù là ADMIN). Thêm một cái bẫy nữa: rule @PreAuthorize nằm ở OrderService, nếu bạn thay service bằng @MockitoBean (cách hay làm trong test controller) thì mock không có lớp kiểm tra quyền, hai test mong đợi 403 sẽ nhận 200. Muốn test phân quyền ở method thì dùng service thật như trên (mock tầng repository bên dưới), hoặc test riêng service bằng @SpringBootTest.
# production checklist
Trước khi đẩy code từ staging lên production, nhớ tick đủ mấy mục này:
- Chỉ tắt CSRF khi ứng dụng thật sự stateless (REST API không dùng cookie session).
- Cấu hình CORS chặt: bỏ hẳn * ở allowedOrigins trên môi trường prod.
- Verify JWT cho đủ: check Issuer, Audience và Expiration. Spring Boot tự kiểm
exp/nbf, kiểmisskhi khai báoissuer-uri, còn audience phải khai báo thêmspring.security.oauth2.resourceserver.jwt.audiences. - Dùng Method Security (@PreAuthorize) cho mọi thao tác write/delete nhạy cảm ở service layer.
- Tự xử lý exception để không lộ cấu trúc bên trong.
- Kiểm tra security headers (X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security). Spring Security bật sẵn mấy header này (HSTS chỉ gửi qua HTTPS), đừng lỡ tay tắt; cần thêm thì bổ sung Content-Security-Policy.
- Có rate limiting để chặn brute-force.
- Bật audit log, ghi lại mọi lần đăng nhập thành công/thất bại.
Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!
Happy coding 😎 👍🏻 🚀 🔥.