Spring Security 6 & JWT Authentication: Hướng Dẫn Toàn Diện Spring Boot 3 (2026)
Trong bài viết này, chúng ta sẽ cùng nhau xây dựng một cơ chế xác thực JWT stateless, chuẩn OAuth2 Resource Server trong Spring Boot 3 và Spring Security 6 — từ khâu tạo token đến xác thực và phân quyền. Điểm đặc biệt: chúng ta sẽ tận dụng thư viện chính chủ của Spring (spring-boot-starter-oauth2-resource-server) để giải mã JWT thay vì tự viết JwtFilter thủ công — giúp mã nguồn gọn nhẹ hơn đến 70% và tuân thủ chuẩn RFC.

1. Những Thay Đổi Lớn Trong Spring Security 6 Bạn Cần Biết
Nếu bạn từng làm việc với Spring Security 5, cấu hình thường trông như thế này:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.httpBasic();
}
}
Spring Security 6 đã khai tử hoàn toàn cách tiếp cận này. Dưới đây là bảng tóm tắt những thay đổi cốt lõi:
| Spring Security 5 | Spring Security 6 | Ghi chú |
|---|---|---|
extends WebSecurityConfigurerAdapter |
SecurityFilterChain Bean |
Không còn kế thừa, dùng Bean thay thế |
.and() |
Lambda DSL | Mỗi configurer nhận lambda |
antMatchers(), mvcMatchers() |
requestMatchers() |
Hợp nhất thành một method duy nhất |
@EnableGlobalMethodSecurity |
@EnableMethodSecurity |
Bật sẵn @PreAuthorize mặc định |
authorizeRequests() |
authorizeHttpRequests() |
Tên method mới phản ánh đúng chức năng |
1.1 Kế thừa → Bean
Không còn extends WebSecurityConfigurerAdapter nữa. Thay vào đó, bạn định nghĩa một Bean SecurityFilterChain nhận HttpSecurity làm tham số:
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// Cấu hình tại đây
return http.build();
}
1.2 .and() → Lambda DSL
Cách viết chuỗi với .and() đã được thay thế bằng Lambda DSL — mỗi configurer nhận một lambda, giúp code gọn và dễ đọc hơn:
// Cũ (Spring Security 5)
http.authorizeRequests()
.antMatchers("/api/**").authenticated()
.and()
.httpBasic();
// Mới (Spring Security 6) — Lambda DSL
http.authorizeHttpRequests(auth -> auth.requestMatchers("/api/**").authenticated())
.httpBasic(Customizer.withDefaults());
1.3 antMatchers() → requestMatchers()
antMatchers(), mvcMatchers(), regexMatchers() được hợp nhất thành một method duy nhất: requestMatchers().
1.4 Method Security: @EnableGlobalMethodSecurity → @EnableMethodSecurity
Annotation @EnableGlobalMethodSecurity đã bị xóa bỏ trong Spring Security 6 và phải được thay thế bằng @EnableMethodSecurity. Điểm thuận tiện: @EnableMethodSecurity bật @PreAuthorize/@PostAuthorize mặc định mà không cần cấu hình prePostEnabled = true.
// Cũ
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig { ... }
// Mới
@EnableMethodSecurity
public class SecurityConfig { ... }

2. Tư Duy Mới: Dùng Spring OAuth2 Resource Server Để Giải Mã JWT
Nhiều lập trình viên khi làm JWT authentication thường có thói quen tự viết một Custom Filter để:
- Trích xuất token từ
Authorizationheader - Giải mã và verify chữ ký
- Set
AuthenticationvàoSecurityContext
Đó là cách làm của thời Spring Security 5.
Với Spring Security 6 và sự có mặt của spring-boot-starter-oauth2-resource-server, bạn không cần tự viết filter nữa. Spring đã cung cấp sẵn:
| Thành phần | Vai trò |
|---|---|
BearerTokenAuthenticationFilter |
Tự động trích xuất token từ Header |
JwtDecoder |
Giải mã và xác thực chữ ký JWT |
JwtAuthenticationProvider |
Xây dựng Authentication object |
JwtAuthenticationConverter |
Chuyển đổi JWT thành Authentication với authorities |
2.1 Tại sao nên dùng OAuth2 Resource Server?
- Chuẩn RFC: Tuân thủ OAuth2 Bearer Token specification (RFC 6750)
- Ít code hơn 70%: Không cần viết Filter, không cần xử lý parse header thủ công
- Tích hợp sẵn với JWK: Hỗ trợ lấy public key từ JWK Set URI của Authorization Server
- Bảo trì tốt hơn: Code ít hơn → ít lỗi hơn → dễ bảo trì hơn
- Tích hợp với Spring Security ACL: Dễ dàng mở rộng với các tính năng bảo mật nâng cao
2.2 Khi nào vẫn cần Custom Filter?
Chỉ khi bạn có yêu cầu đặc biệt như:
- Token được gửi trong Cookie thay vì Header
- Cần log mọi request chứa token (audit logging)
- Tích hợp với hệ thống authentication không chuẩn OAuth2
- Cần xử lý token ở định dạng đặc biệt (ví dụ: token được encrypt)
Còn trong 90% trường hợp, OAuth2 Resource Server là lựa chọn tối ưu.
3. Thực Chiến: Xây Dựng Hệ Thống JWT Authentication Hoàn Chỉnh
Phần này sẽ hướng dẫn bạn xây dựng toàn bộ hệ thống xác thực JWT với Spring Security 6, sử dụng thuật toán bất đối xứng RS256 (Public/Private Key) — bao gồm cả tạo token và xác thực token.
3.1 Cấu hình Dependencies
pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- OAuth2 Resource Server - Quan trọng để giải mã JWT -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<!-- JJWT - Thư viện tạo và verify JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
⚠️ Lưu ý: JJWT 0.12.x có API hoàn toàn khác so với 0.9.x. Đảm bảo dùng đúng version.
3.2 Tạo RSA Public/Private Key Pair cho thuật toán RS256
💡 Best Practice: Nên mã hóa JWT bằng thuật toán bất đối xứng RS256 thay vì HS256 vì Resource Server chỉ cần giữ public key — không bao giờ chạm vào private key.
Bước 1: Tạo private key (PKCS#8 format)
openssl genrsa -out private.pem 2048
openssl pkcs8 -topk8 -inform PEM -outform PEM -nocrypt -in private.pem -out private-key.pem
Bước 2: Tạo public key từ private key
openssl rsa -pubout -in private.pem -out public.pem
Bước 3: Đặt file public.pem vào src/main/resources/ của Resource Server.
Bước 4: Cấu hình application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
public-key-location: classpath:public.pem
jwt:
private-key: classpath:private-key.pem # Dùng để sign token
expiration: 3600000 # 1 hour
💡 Nếu bạn dùng Authorization Server riêng (ví dụ Keycloak), có thể cấu hình issuer-uri hoặc jwk-set-uri thay vì public-key-location:
spring.security.oauth2.resourceserver.jwt.issuer-uri: https://your-auth-server.com
3.3 Cấu hình UserDetailsService và PasswordEncoder
Trước khi tạo token, chúng ta cần cấu hình cơ chế lưu trữ và xác thực user. Dưới đây là ví dụ dùng InMemoryUserDetailsManager để test nhanh:
package com.example.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
@Configuration
public class UserDetailsConfig {
@Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) {
User.UserBuilder users = User.builder();
// Tạo user với role ADMIN
var admin = users
.username("admin")
.password(passwordEncoder.encode("admin123"))
.roles("ADMIN")
.build();
// Tạo user với role USER
var user = users
.username("user")
.password(passwordEncoder.encode("user123"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(admin, user);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
💡 Trong môi trường production, bạn nên thay InMemoryUserDetailsManager bằng implementation lưu user trong database.
3.4 Tạo JWT Token (Login Endpoint)
Đây là phần tạo token mà nhiều bài viết bỏ qua. Chúng ta sẽ dùng JJWT library để tạo JWT với thuật toán RS256.
JwtService.java:
package com.example.security.service;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class JwtService {
@Value("${jwt.expiration:3600000}")
private long expiration;
private final PrivateKey privateKey;
public JwtService(@Value("${jwt.private-key}") Resource privateKeyResource)
throws IOException, NoSuchAlgorithmException {
this.privateKey = loadPrivateKey(privateKeyResource);
}
private PrivateKey loadPrivateKey(Resource resource) throws IOException, NoSuchAlgorithmException {
// Dùng InputStream để tránh lỗi khi chạy từ JAR
try (BufferedReader reader = new BufferedReader(
new InputStreamReader(resource.getInputStream()))) {
String key = reader.lines().collect(Collectors.joining("\n"));
String privateKeyContent = key
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", "");
byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent);
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
return keyFactory.generatePrivate(keySpec);
}
}
public String generateToken(String username, List
<String> roles) {
Date now = new Date();
Date expiryDate = new Date(now.getTime() + expiration);
return Jwts.builder()
.subject(username)
.claim("roles", roles)
.issuedAt(now)
.expiration(expiryDate)
.signWith(privateKey, SignatureAlgorithm.RS256)
.compact();
}
}
AuthController.java — Login Endpoint:
package com.example.security.controller;
import com.example.security.dto.LoginRequest;
import com.example.security.dto.LoginResponse;
import com.example.security.service.JwtService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtService jwtService;
@PostMapping("/login")
public LoginResponse login(@RequestBody LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(
request.getUsername(),
request.getPassword()
)
);
List
<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
String token = jwtService.generateToken(request.getUsername(), roles);
return new LoginResponse(token, "Bearer");
}
}
LoginRequest.java và LoginResponse.java:
// LoginRequest.java
package com.example.security.dto;
import lombok.Data;
@Data
public class LoginRequest {
private String username;
private String password;
}
// LoginResponse.java
package com.example.security.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class LoginResponse {
private String accessToken;
private String tokenType;
}
3.5 Cấu hình AuthenticationManager Bean
Để AuthController có thể inject AuthenticationManager, chúng ta cần định nghĩa Bean này trong SecurityConfig:
package com.example.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.List;
@Configuration
public class AuthenticationManagerConfig {
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(List.of(authProvider));
}
}
3.6 Cấu hình SecurityFilterChain Bean với jwt() decoder
Đây là phần quan trọng nhất — cấu hình SecurityFilterChain với Lambda DSL và kích hoạt OAuth2 Resource Server JWT.
package com.example.security.config;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.authentication.AuthenticationEntryPoint;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // Thay thế cho @EnableGlobalMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final AccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 1. Tắt CSRF vì API stateless
.csrf(csrf -> csrf.disable())
// 2. Cấu hình CORS (nếu cần)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// 3. Stateless session (không tạo session)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 4. Exception handling cho 401/403
.exceptionHandling(ex -> ex
.authenticationEntryPoint(jwtAuthenticationEntryPoint)
.accessDeniedHandler(jwtAccessDeniedHandler)
)
// 5. Phân quyền endpoint
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
.anyRequest().authenticated()
)
// 6. Kích hoạt OAuth2 Resource Server với JWT
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthenticationConverter())
)
);
return http.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList("http://localhost:3000", "https://your-domain.com"));
configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
// Nếu JWT chứa roles trong claim "roles"
// và giá trị là ["ADMIN", "USER"] (không có prefix ROLE_)
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
// (Tùy chọn) Đặt claim làm principal name (mặc định là "sub")
jwtAuthenticationConverter.setPrincipalClaimName("sub");
return jwtAuthenticationConverter;
}
}
Giải thích code:
csrf(csrf -> csrf.disable()): Stateless API không cần CSRF protectionsessionManagement(...STATELESS): Không tạo session, mỗi request đều độc lậpexceptionHandling(...): Custom xử lý lỗi 401 và 403 (định nghĩa ở Mục 4).oauth2ResourceServer(oauth2 -> oauth2.jwt(...)): Kích hoạt cơ chế xác thực JWT của SpringjwtAuthenticationConverter(): Custom converter để map roles từ JWT claims

4. Xử Lý Lỗi Exception Khi Token Bị Hết Hạn Hoặc Khai Báo Sai
Mặc định, khi JWT hết hạn hoặc không hợp lệ, Spring Security trả về 403 Forbidden thay vì 401 Unauthorized. Điều này không chuẩn với REST API. Chúng ta cần custom AuthenticationEntryPoint và AccessDeniedHandler.
4.1 Custom AuthenticationEntryPoint (xử lý 401)
package com.example.security.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response,
AuthenticationException authException) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", HttpServletResponse.SC_UNAUTHORIZED);
body.put("error", "Unauthorized");
body.put("message", authException.getMessage());
body.put("timestamp", Instant.now().toString());
body.put("path", request.getRequestURI());
new ObjectMapper().writeValue(response.getOutputStream(), body);
}
}
4.2 Custom AccessDeniedHandler (xử lý 403)
package com.example.security.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
@Component
public class JwtAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request, HttpServletResponse response,
AccessDeniedException accessDeniedException) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", HttpServletResponse.SC_FORBIDDEN);
body.put("error", "Forbidden");
body.put("message", accessDeniedException.getMessage());
body.put("timestamp", Instant.now().toString());
body.put("path", request.getRequestURI());
new ObjectMapper().writeValue(response.getOutputStream(), body);
}
}
4.3 Đăng ký handlers trong SecurityFilterChain
// Trong SecurityConfig.java
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final AuthenticationEntryPoint jwtAuthenticationEntryPoint;
private final AccessDeniedHandler jwtAccessDeniedHandler;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(jwtAuthenticationEntryPoint) // 401
.accessDeniedHandler(jwtAccessDeniedHandler) // 403
)
.authorizeHttpRequests(authz -> authz
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
);
return http.build();
}
}
💡 Lưu ý: Trong Spring Security 6.2+, http.exceptionHandling() vẫn được hỗ trợ với Lambda DSL.
5. Lỗi Thường Gặp Và Cách Khắc Phục
🔴 Lỗi 1: 403 Forbidden dù đã gửi đúng Bearer Token
Nguyên nhân: Spring Security mặc định tìm kiếm prefix ROLE_ trong Authority nhưng JWT Payload lại thiếu prefix này.
Cách fix: Cấu hình JwtGrantedAuthoritiesConverter.setAuthorityPrefix("ROLE_"):
JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter();
converter.setAuthorityPrefix("ROLE_"); // Thêm prefix ROLE_
converter.setAuthoritiesClaimName("roles"); // Claim chứa roles
🔴 Lỗi 2: @PreAuthorize("hasRole('ADMIN')") không hoạt động
Nguyên nhân: Chưa bật @EnableMethodSecurity hoặc đang dùng @EnableGlobalMethodSecurity cũ.
Cách fix:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // ← BẮT BUỘC cho @PreAuthorize hoạt động
public class SecurityConfig { ... }
🔴 Lỗi 3: JWT không được giải mã dù đã cấu hình public key
Nguyên nhân: Có thể bạn chưa thêm dependency spring-boot-starter-oauth2-resource-server.
Cách fix: Thêm vào pom.xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
🔴 Lỗi 4: antMatchers() không còn được dùng
Nguyên nhân: Spring Security 6 đã loại bỏ antMatchers().
Cách fix: Thay bằng requestMatchers():
// Cũ
http.authorizeRequests().antMatchers("/api/**").authenticated();
// Mới
http.authorizeHttpRequests(auth -> auth.requestMatchers("/api/**").authenticated());
🔴 Lỗi 5: JwtAuthenticationConverter không tìm thấy
Nguyên nhân: Thiếu import hoặc dependency.
Cách fix: Đảm bảo import đúng:
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
🔴 Lỗi 6: AuthenticationManager Bean không tìm thấy
Nguyên nhân: Chưa định nghĩa Bean AuthenticationManager trong SecurityConfig.
Cách fix: Thêm Bean như ở Mục 3.5:
@Bean
public AuthenticationManager authenticationManager(
UserDetailsService userDetailsService,
PasswordEncoder passwordEncoder) {
DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
authProvider.setUserDetailsService(userDetailsService);
authProvider.setPasswordEncoder(passwordEncoder);
return new ProviderManager(List.of(authProvider));
}
6. Best Practices Cho Hệ Thống JWT Stateless
✅ 1. Luôn disable CSRF cho REST API
http.csrf(csrf -> csrf.disable());
Vì JWT stateless không dùng cookie session, CSRF không phải là mối đe dọa.
✅ 2. Dùng RS256 thay vì HS256
- HS256 (đối xứng): Cùng một secret key để sign và verify → rủi ro nếu lộ key.
- RS256 (bất đối xứng): Private key sign, public key verify → Resource Server chỉ cần public key.
✅ 3. Thiết lập thời gian hết hạn token hợp lý
- Access Token: 15 phút – 1 giờ
- Refresh Token: 7 – 30 ngày (nếu có)
✅ 4. Luôn trả về JSON cho lỗi authentication/authorization
Không để Spring trả về HTML error page mặc định. Dùng custom AuthenticationEntryPoint và AccessDeniedHandler.
✅ 5. Log đầy đủ nhưng không leak thông tin nhạy cảm
logging:
level:
org.springframework.security: DEBUG # Chỉ ở môi trường dev
✅ 6. Sử dụng @PreAuthorize thay vì cấu hình trong SecurityFilterChain
Phân quyền ở method level giúp code rõ ràng và linh hoạt hơn:
@RestController
@RequestMapping("/api/admin")
@PreAuthorize("hasRole('ADMIN')") // Class-level
public class AdminController {
@PreAuthorize("hasAuthority('reports:read')") // Method-level
@GetMapping("/reports")
public List
<Report> getReports() { ... }
@PreAuthorize("hasAuthority('orders:read')")
@GetMapping("/orders")
public List
<Order> getOrders() { ... }
}
✅ 7. Lưu token một cách an toàn ở client
- Web: Lưu trong HttpOnly Cookie (chống XSS) + Secure flag
- Mobile: Lưu trong Secure Storage của OS
- Không bao giờ lưu token trong localStorage (dễ bị XSS)
✅ 8. Validate đầy đủ các claim
Nên validate:
iss(issuer) — đúng với Authorization Serveraud(audience) — kiểm tra ứng dụng của bạnexp(expiration) — kiểm tra token còn hạniat(issued at) — token không được tạo trong tương lai
7. So Sánh Kiến Trúc: Security Filter Chain (Spring) vs Guards (NestJS)
Nếu bạn đã từng làm việc với NestJS, bạn sẽ thấy sự tương đồng thú vị. Điều này giúp các developer đa nền tảng dễ dàng tiếp cận:
| Spring Security | NestJS | Mô tả |
|---|---|---|
SecurityFilterChain |
Guard |
Chain of Responsibility cho bảo mật |
AuthenticationFilter |
AuthGuard |
Xác thực người dùng |
AuthorizationFilter |
RolesGuard |
Phân quyền truy cập |
@PreAuthorize |
@Roles() / @UseGuards() |
Phân quyền ở method/controller |
AuthenticationEntryPoint |
Exception filter cho 401 | Xử lý lỗi chưa xác thực |
AccessDeniedHandler |
Exception filter cho 403 | Xử lý lỗi không đủ quyền |
SecurityContext |
Request object |
Lưu thông tin authentication |
Cả hai framework đều áp dụng Chain of Responsibility pattern cho bảo mật. Điểm khác biệt: Spring Security cung cấp sẵn OAuth2 Resource Server tích hợp, trong khi NestJS thường dùng thư viện bên thứ ba như @nestjs/passport.
8. Câu Hỏi Thường Gặp (FAQ)
❓ Tại sao không nên dùng WebSecurityConfigurerAdapter nữa?
WebSecurityConfigurerAdapter bị deprecated từ Spring Security 5.7 và bị xóa hoàn toàn trong Spring Security 6. Cách tiếp cận mới dùng SecurityFilterChain Bean giúp cấu hình linh hoạt hơn, hỗ trợ nhiều security chain khác nhau trong cùng một ứng dụng (ví dụ: cấu hình khác nhau cho API và Admin).
❓ Làm thế nào để xử lý Refresh Token an toàn với Spring Security 6?
Có hai cách tiếp cận phổ biến:
- Refresh Token trong Cookie HttpOnly: Lưu refresh token trong cookie secure, http-only để chống XSS
- Refresh Token endpoint: Tạo endpoint
/api/auth/refreshnhận refresh token (gửi qua body hoặc header) và trả về access token mới
Với Spring Security 6, bạn có thể tự implement AuthenticationProvider hoặc dùng OAuth2 Authorization Server (Spring Security 6 cung cấp module spring-security-oauth2-authorization-server riêng).
❓ Có cần tự viết JwtFilter không?
Không, nếu bạn dùng spring-boot-starter-oauth2-resource-server. Spring đã cung cấp BearerTokenAuthenticationFilter làm việc này. Chỉ tự viết filter khi có yêu cầu đặc biệt (token trong cookie, custom header, etc.).
❓ Làm sao để debug JWT authentication?
Thêm cấu hình log:
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.oauth2: DEBUG
Và kiểm tra token bằng công cụ như jwt.io để xem payload.
❓ Sự khác biệt giữa opaque token và JWT là gì?
- JWT (JSON Web Token): Self-contained, chứa đầy đủ thông tin (claims) trong token, không cần gọi đến Authorization Server để verify.
- Opaque Token: Token không chứa thông tin, cần gọi đến Authorization Server (Introspection Endpoint) để lấy thông tin user và permissions.
Khi nào dùng JWT? Khi bạn muốn giảm số lượng request đến Authorization Server, tăng performance.
Khi nào dùng Opaque Token? Khi bạn muốn thu hồi token ngay lập tức (JWT không thể thu hồi cho đến khi hết hạn).
❓ Làm thế nào để kiểm tra API với Postman?
- Gửi request POST đến
/api/auth/loginvới body JSON:{ "username": "admin", "password": "admin123" } - Copy token từ response
- Gửi request GET đến API cần bảo vệ với Header:
Authorization: Bearer <your-token>
9. Kết Luận
Spring Security 6 mang đến một làn gió mới với Lambda DSL, SecurityFilterChain, và sự tích hợp mạnh mẽ với OAuth2 Resource Server. Việc nâng cấp có thể gây bỡ ngỡ ban đầu, nhưng khi đã nắm được tư duy mới, bạn sẽ thấy code sạch hơn, an toàn hơn và dễ bảo trì hơn.
Tóm tắt những điểm chính:
- ✅ Khai tử
WebSecurityConfigurerAdapter→ dùngSecurityFilterChainBean - ✅
.and()→ Lambda DSL - ✅
antMatchers()→requestMatchers() - ✅
@EnableGlobalMethodSecurity→@EnableMethodSecurity - ✅ Tận dụng
spring-boot-starter-oauth2-resource-serverthay vì tự viết filter - ✅ Tạo token với JJWT và thuật toán RS256
- ✅ Dùng RS256 và disable CSRF cho stateless API
- ✅ Custom
AuthenticationEntryPointvàAccessDeniedHandlerđể trả về JSON chuẩn - ✅ Cấu hình
AuthenticationManagervớiDaoAuthenticationProvidervàPasswordEncoder - ✅ Xử lý Refresh Token với Cookie HttpOnly hoặc endpoint riêng