Bảo vệ REST API bằng Spring Security và JWT
1. Tổng quan
REST API thường không dùng session. Mỗi request phải tự mang theo thông tin xác thực, và JWT (JSON Web Token) là cách phổ biến nhất để làm việc này.
Bài viết hướng dẫn bạn bảo vệ một API quản lý đơn hàng bằng Spring Security 6 và Spring Boot 3. Chúng ta sẽ phát JWT khi người dùng đăng nhập, xác thực token ở mọi request và phân quyền theo role. Phần cuối là test tự động cho các hành vi chính.
Chúng ta không viết filter JWT thủ công. Thay vào đó, bài dùng OAuth2 Resource Server có sẵn trong Spring Security. Cách này ít code hơn và an toàn hơn.
2. Dependency Maven
Bạn cần các dependency sau trong pom.xml:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Starter oauth2-resource-server kéo theo thư viện Nimbus JOSE + JWT. Thư viện này lo phần ký và kiểm tra chữ ký token. Bạn không cần thêm jjwt hay thư viện JWT nào khác.
3. Cấu hình khóa bí mật
Để đơn giản, chúng ta ký token bằng thuật toán đối xứng HS256. Cùng một khóa bí mật được dùng để ký và để kiểm tra chữ ký.
jwt.secret=${JWT_SECRET:doi-chuoi-nay-thanh-mot-khoa-bi-mat-dai-it-nhat-32-byte}
jwt.expiration-minutes=15
Biểu thức ${JWT_SECRET:...} đọc khóa từ biến môi trường JWT_SECRET. Nếu biến này không tồn tại, Spring dùng giá trị mặc định phía sau dấu hai chấm. Giá trị mặc định chỉ dành cho môi trường dev.
Lưu ý: HS256 yêu cầu khóa dài tối thiểu 256 bit, tức 32 byte. Nếu khóa ngắn hơn, Nimbus sẽ ném
KeyLengthExceptionngay lần đầu ký token.
4. Cấu hình Spring Security
4.1. JwtEncoder, JwtDecoder và người dùng
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Value("${jwt.secret}")
private String secret;
@Bean
SecretKey jwtSecretKey() {
return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
}
@Bean
JwtEncoder jwtEncoder(SecretKey key) {
return new NimbusJwtEncoder(new ImmutableSecret<>(key));
}
@Bean
JwtDecoder jwtDecoder(SecretKey key) {
return NimbusJwtDecoder.withSecretKey(key)
.macAlgorithm(MacAlgorithm.HS256)
.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
var customer = User.withUsername("alice")
.password(encoder.encode("alice123")).roles("CUSTOMER").build();
var admin = User.withUsername("bob")
.password(encoder.encode("bob123")).roles("CUSTOMER", "ADMIN").build();
return new InMemoryUserDetailsManager(customer, admin);
}
@Bean
AuthenticationManager authenticationManager(UserDetailsService uds, PasswordEncoder encoder) {
var provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(uds);
provider.setPasswordEncoder(encoder);
return new ProviderManager(provider);
}
}
Vai trò của từng bean:
SecretKeybọc chuỗi bí mật thành khóa HMAC-SHA256.JwtEncoderdùng khóa này để ký token khi người dùng đăng nhập.JwtDecoderkiểm tra chữ ký và thời hạn (exp) của token trong mỗi request. Resource Server tự động dùng bean này.InMemoryUserDetailsManagertạo hai người dùng mẫu. Khi làm thật, bạn thay bằng một implementation đọc từ database.AuthenticationManagerkiểm tra username và password ở endpoint đăng nhập.
4.2. SecurityFilterChain
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/api/auth/login").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("");
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
Bean này nằm trong cùng lớp SecurityConfig. Từng phần làm những việc sau:
csrf.disable(): API không dùng cookie để xác thực, nên không bị tấn công CSRF.STATELESS: Spring không tạoHttpSession. Mỗi request được xác thực lại từ token.authorizeHttpRequests: endpoint đăng nhập mở công khai./api/admin/**chỉ dành cho roleADMIN. Mọi endpoint còn lại yêu cầu đăng nhập.oauth2ResourceServer(...jwt...): bật filter đọc headerAuthorization: Bearer <token>và giải mã token bằngJwtDecoder.
Mặc định, Spring đọc quyền từ claim scope và thêm tiền tố SCOPE_. Chúng ta muốn dùng hasRole("ADMIN"), nên JwtAuthenticationConverter được cấu hình lại để đọc claim roles. Tiền tố được bỏ đi vì giá trị trong token đã có sẵn dạng ROLE_ADMIN.
5. Phát token khi đăng nhập
5.1. TokenService
@Service
public class TokenService {
private final JwtEncoder encoder;
private final long expirationMinutes;
public TokenService(JwtEncoder encoder,
@Value("${jwt.expiration-minutes}") long expirationMinutes) {
this.encoder = encoder;
this.expirationMinutes = expirationMinutes;
}
public String generateToken(Authentication authentication) {
Instant now = Instant.now();
List<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("nonla-shop")
.issuedAt(now)
.expiresAt(now.plus(expirationMinutes, ChronoUnit.MINUTES))
.subject(authentication.getName())
.claim("roles", roles)
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
public long getExpirationSeconds() {
return expirationMinutes * 60;
}
}
Token chứa các claim sau: người phát hành (iss), thời điểm phát (iat), thời điểm hết hạn (exp), username (sub) và danh sách role.
Dòng JwsHeader.with(MacAlgorithm.HS256) là bắt buộc. Nếu thiếu, NimbusJwtEncoder mặc định chọn RS256. Khi đó nó không tìm được khóa phù hợp và ném lỗi Failed to select a JWK signing key.
5.2. AuthController
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final TokenService tokenService;
public AuthController(AuthenticationManager authenticationManager, TokenService tokenService) {
this.authenticationManager = authenticationManager;
this.tokenService = tokenService;
}
public record LoginRequest(String username, String password) {}
public record TokenResponse(String accessToken, String tokenType, long expiresIn) {}
@PostMapping("/login")
public TokenResponse login(@RequestBody LoginRequest request) {
Authentication auth = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(
request.username(), request.password()));
String token = tokenService.generateToken(auth);
return new TokenResponse(token, "Bearer", tokenService.getExpirationSeconds());
}
}
authenticate() kiểm tra username và password qua DaoAuthenticationProvider. Nếu sai, phương thức ném BadCredentialsException. Spring Security dịch lỗi này thành HTTP 401. Nếu đúng, controller tạo token và trả về cho client.
Gọi thử bằng curl:
curl -s -X POST localhost:8080/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"alice","password":"alice123"}'
{"accessToken":"eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJub25sYS1zaG9wIiwic3ViIjoiYWxpY2UiLC...","tokenType":"Bearer","expiresIn":900}
6. Endpoint được bảo vệ
@RestController
@RequestMapping("/api")
public class OrderController {
public record Order(long id, String customer, BigDecimal total) {}
private final List<Order> orders = List.of(
new Order(1, "alice", new BigDecimal("250000")),
new Order(2, "bob", new BigDecimal("990000")));
@GetMapping("/orders")
public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
return orders.stream()
.filter(o -> o.customer().equals(jwt.getSubject()))
.toList();
}
@GetMapping("/admin/orders")
public List<Order> allOrders() {
return orders;
}
}
@AuthenticationPrincipal Jwt jwt inject token đã được xác thực vào controller. Bạn lấy username bằng jwt.getSubject(). Để đọc claim khác, dùng jwt.getClaim("roles").
Thử gọi với ba trường hợp khác nhau:
# Không có token
curl -i localhost:8080/api/orders
# alice có token hợp lệ
curl -s localhost:8080/api/orders -H "Authorization: Bearer $TOKEN"
# alice gọi endpoint admin
curl -i localhost:8080/api/admin/orders -H "Authorization: Bearer $TOKEN"
HTTP/1.1 401
WWW-Authenticate: Bearer
[{"id":1,"customer":"alice","total":250000}]
HTTP/1.1 403
WWW-Authenticate: Bearer error="insufficient_scope", ...
Kết quả cho thấy hai mã lỗi khác nhau. 401 nghĩa là chưa xác thực: thiếu token, token sai chữ ký hoặc token đã hết hạn. 403 nghĩa là đã xác thực nhưng không đủ quyền.
7. Viết test
spring-security-test cung cấp post-processor jwt(). Nó giả lập một JWT đã xác thực mà không cần ký token thật. Chúng ta kết hợp nó với một test end-to-end có đăng nhập thật.
@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {
@Autowired
MockMvc mockMvc;
@Autowired
ObjectMapper objectMapper;
@Test
void whenNoToken_thenUnauthorized() throws Exception {
MvcResult result = mockMvc.perform(get("/api/orders")).andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(401);
}
@Test
void whenLoginAndCallWithToken_thenReturnOwnOrders() throws Exception {
String body = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("""
{"username":"alice","password":"alice123"}
"""))
.andReturn().getResponse().getContentAsString();
String token = objectMapper.readTree(body).get("accessToken").asText();
MvcResult result = mockMvc.perform(get("/api/orders")
.header("Authorization", "Bearer " + token))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(200);
assertThat(result.getResponse().getContentAsString())
.contains("\"customer\":\"alice\"")
.doesNotContain("\"customer\":\"bob\"");
}
@Test
void whenWrongPassword_thenUnauthorized() throws Exception {
MvcResult result = mockMvc.perform(post("/api/auth/login")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"username\":\"alice\",\"password\":\"sai\"}"))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(401);
}
@Test
void whenCustomerCallsAdminEndpoint_thenForbidden() throws Exception {
MvcResult result = mockMvc.perform(get("/api/admin/orders")
.with(jwt().jwt(j -> j.subject("alice"))
.authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
.andReturn();
assertThat(result.getResponse().getStatus()).isEqualTo(403);
}
}
Các static import cần dùng là MockMvcRequestBuilders.*, SecurityMockMvcRequestPostProcessors.jwt và Assertions.assertThat của AssertJ.
Test thứ hai kiểm tra toàn bộ luồng. Nó gọi endpoint đăng nhập, lấy token, rồi dùng token để gọi API. Test này phát hiện được lỗi cấu hình encoder và decoder, ví dụ quên JwsHeader hay khóa quá ngắn.
Test cuối dùng jwt() để kiểm tra riêng phần phân quyền. Lưu ý rằng .authorities(...) ghi đè converter, nên bạn phải truyền đúng tên authority ROLE_CUSTOMER.
8. Lỗi hay gặp và khi nào không nên dùng
Các lỗi thường gặp:
- Khóa quá ngắn: khóa dưới 32 byte gây
KeyLengthExceptionvới HS256. - Quên header HS256:
JwtEncoderbáoFailed to select a JWK signing key. - Sai tiền tố authority: để converter mặc định thì quyền có dạng
SCOPE_.... Khi đóhasRole("ADMIN")luôn trả 403. Bạn hãy kiểm tra claim và tiền tố cho khớp nhau. - Hardcode secret trong repo: luôn đọc secret từ biến môi trường hoặc secret manager.
- Đưa dữ liệu nhạy cảm vào payload: payload JWT chỉ được mã hóa Base64URL, không được mã hóa bí mật. Bất kỳ ai có token đều đọc được nội dung. Đừng đặt mật khẩu, số thẻ hay thông tin cá nhân vào token.
Lưu ý: JWT không thể thu hồi trước khi hết hạn nếu bạn không thêm cơ chế phụ như blacklist. Hãy giữ access token ngắn hạn (5–15 phút). Nếu cần phiên dài, dùng thêm refresh token lưu ở server để có thể thu hồi.
Khi nào không nên dùng cách này:
- Ứng dụng web render phía server, như Thymeleaf: session kèm cookie đơn giản và an toàn hơn.
- Cần đăng xuất hoặc khóa tài khoản có hiệu lực ngay lập tức: session hoặc opaque token kèm introspection phù hợp hơn.
- Hệ thống nhiều service: chia sẻ một khóa HMAC cho mọi service rất rủi ro. Khi đó, bạn nên dùng authorization server riêng, như Spring Authorization Server hoặc Keycloak. Authorization server ký token bằng RSA hoặc EC, còn các service chỉ cần cấu hình
spring.security.oauth2.resourceserver.jwt.issuer-uriđể lấy public key qua JWKS.
9. Kết luận
Bài viết đã xây dựng một REST API stateless được bảo vệ bằng JWT, gồm các bước sau:
- Dùng
NimbusJwtEncodervàNimbusJwtDecodervới khóa HS256. - Phát token ở endpoint
/api/auth/loginthông quaAuthenticationManager. - Để OAuth2 Resource Server tự xác thực header
Bearer. - Ánh xạ claim
rolesthành authority để dùnghasRole. - Kiểm tra các trường hợp 401, 403 và luồng thành công bằng MockMvc và AssertJ.
Từ nền tảng này, bạn có thể thay InMemoryUserDetailsManager bằng người dùng lưu trong database, thêm refresh token, hoặc chuyển sang khóa bất đối xứng khi hệ thống mở rộng thành nhiều service.