JWT: xác thực stateless cho REST API
Tiếp đi: JWT.
Ở bài 4.4.2, login chỉ kiểm tra credentials. Bài trước dùng session để nhớ đăng nhập. Giờ ta trả lời: sau khi login, làm sao request tiếp theo chứng minh đã đăng nhập mà không gửi lại password và server không cần nhớ session? Đó là JWT (JSON Web Token).
1. Bài toán
POST /api/auth/login {"username":"john","password":"<password>"}
→ 200 Credentials verified
GET /api/orders
→ 401 Unauthorized
shop-app là REST API, có mobile app và SPA gọi vào, chạy nhiều instance sau load balancer. Ta muốn client gửi:
GET /api/orders
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
2. Vấn đề
- HTTP Basic: password đi trong mọi request.
- Session: cần store chung (Redis) khi scale, không hợp với mobile và service-to-service.
- Token ngẫu nhiên lưu DB: mỗi request phải tra DB.
3. Giải pháp: JWT tự chứa thông tin, có chữ ký
HEADER.PAYLOAD.SIGNATURE
Header:
{ "alg": "HS256", "typ": "JWT" }
Payload (claims):
{
"sub": "john",
"roles": ["ROLE_USER"],
"iss": "shop-app",
"iat": 1791400000,
"exp": 1791401800
}
| Claim | Ý nghĩa |
|---|---|
sub | Subject: user id hoặc username (nên dùng id ổn định) |
iss | Issuer: ai phát hành |
aud | Audience: API nào được nhận |
iat / exp | Thời điểm tạo / hết hạn |
jti | ID của token, dùng cho revoke |
roles | Claim tự định nghĩa |
Quan trọng: payload chỉ được Base64URL encode, không mã hóa. Ai có token cũng decode đọc được. Không để password hay dữ liệu nhạy cảm trong payload.
Signature (HS256):
HMACSHA256(base64url(header) + "." + base64url(payload), secret)
Attacker sửa "roles":["ROLE_USER"] thành ROLE_ADMIN nhưng không có secret thì không tạo được chữ ký hợp lệ. JWT phát hiện token bị sửa, nhưng không ngăn người khác dùng token nếu bị đánh cắp.
4. Code từng bước
Bước 1: Dependency. Dùng Resource Server có sẵn của Spring Security thay vì tự viết parser:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
Bước 2: Config secret (không commit secret vào Git):
app.jwt.secret=${JWT_SECRET_BASE64}
# tạo 32 bytes ngẫu nhiên, đặt vào biến môi trường JWT_SECRET_BASE64
openssl rand -base64 32
Bước 3: Code Java. Encoder và Decoder:
@Configuration
public class JwtConfig {
@Bean
public SecretKey jwtSecretKey(@Value("${app.jwt.secret}") String secret) {
byte[] keyBytes = Base64.getDecoder().decode(secret);
if (keyBytes.length < 32) {
throw new IllegalArgumentException("JWT secret must be at least 256 bits");
}
return new SecretKeySpec(keyBytes, "HmacSHA256");
}
@Bean
public JwtEncoder jwtEncoder(SecretKey key) {
return new NimbusJwtEncoder(new ImmutableSecret<>(key));
}
@Bean
public JwtDecoder jwtDecoder(SecretKey key) {
NimbusJwtDecoder decoder = NimbusJwtDecoder.withSecretKey(key)
.macAlgorithm(MacAlgorithm.HS256)
.build();
decoder.setJwtValidator(JwtValidators.createDefaultWithIssuer("shop-app")); // exp + iss
return decoder;
}
}
Phát token khi login:
@Service
public class JwtService {
private final JwtEncoder jwtEncoder;
public JwtService(JwtEncoder jwtEncoder) { this.jwtEncoder = jwtEncoder; }
public String generateToken(Authentication auth) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("shop-app")
.subject(auth.getName())
.id(UUID.randomUUID().toString()) // jti
.issuedAt(now)
.expiresAt(now.plus(15, ChronoUnit.MINUTES))
.claim("roles", auth.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList())
.build();
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
}
}
// AuthService
public String login(LoginRequest request) {
Authentication authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
return jwtService.generateToken(authentication);
}
// AuthController
@PostMapping("/login")
public TokenResponse login(@RequestBody LoginRequest request) {
return new TokenResponse(authService.login(request), "Bearer");
}
public record TokenResponse(String accessToken, String tokenType) {}
SecurityFilterChain: thay httpBasic bằng oauth2ResourceServer, và map claim roles thành authorities (mặc định Spring chỉ đọc scope/scp và thêm prefix SCOPE_):
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable()) // chỉ nhận Bearer qua header, không cookie
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter roles = new JwtGrantedAuthoritiesConverter();
roles.setAuthoritiesClaimName("roles");
roles.setAuthorityPrefix(""); // token đã chứa ROLE_USER / ROLE_ADMIN
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(roles);
return converter;
}
}
Bước 4: Test bằng curl
TOKEN=$(curl -s -X POST localhost:8080/api/auth/login -H "Content-Type: application/json" \
-d '{"username":"john","password":"<password>"}' | jq -r .accessToken)
curl -H "Authorization: Bearer $TOKEN" localhost:8080/api/orders
# Orders for john
curl -i -H "Authorization: Bearer $TOKEN" localhost:8080/api/admin/users
# 403
curl -i -H "Authorization: Bearer abc.def.ghi" localhost:8080/api/orders
# 401, header WWW-Authenticate: Bearer error="invalid_token"
5. Cách hoạt động
HTTP Request (Authorization: Bearer ...)
|
BearerTokenAuthenticationFilter trích token
|
AuthenticationManager
|
JwtAuthenticationProvider
|
JwtDecoder verify signature, exp, iss (aud nếu cấu hình)
|
JwtAuthenticationConverter claims → authorities
|
JwtAuthenticationToken → SecurityContextHolder (chỉ cho request này)
|
AuthorizationFilter → DispatcherServlet → Controller
Session-based JWT-based
Client → Session ID Client → Signed JWT
Server → Session Store → User Server → Verify Token → User
Stateless không có nghĩa là không bao giờ cần Redis/DB: refresh token, revoke, quyền thay đổi vẫn cần state phía server.
6. So sánh
oauth2ResourceServer().jwt() | Tự viết OncePerRequestFilter | |
|---|---|---|
| Code | Vài dòng cấu hình | Tự parse header, verify, set context |
| Validate exp/iss/aud, lỗi chuẩn | Có sẵn, trả WWW-Authenticate đúng chuẩn | Tự làm, dễ sót |
| Đổi sang Keycloak/RS256 | Chỉ đổi issuer-uri | Viết lại |
| Khi nào | Mặc định nên dùng | Định dạng token đặc biệt, hệ thống cũ |
| HS256 (symmetric) | RS256 / ES256 (asymmetric) | |
|---|---|---|
| Khóa | Một secret để ký và verify | Private key ký, public key verify |
| Ai verify được | Ai có secret (cũng ký được!) | Bất kỳ ai có public key (JWKS) |
| Hợp với | Một app tự phát tự kiểm | Nhiều microservice, Keycloak |
| Lưu token ở đâu (web) | Rủi ro |
|---|---|
localStorage | XSS đọc được token |
| Memory (biến JS) | Mất khi reload, cần refresh token |
Cookie HttpOnly; Secure; SameSite | JS không đọc được, nhưng browser tự gửi → phải tính CSRF |
| BFF (backend giữ token, browser chỉ có session) | An toàn nhất cho SPA, thêm một tầng |
7. Khi nào nên dùng / không nên dùng
- Nên: REST API, mobile, microservices, nhiều instance, xác thực bởi IdP chung.
- Access token sống ngắn (5–15 phút).
- Không nên: web app server-side thuần cần logout tức thì; session đơn giản hơn.
- Không để dữ liệu nhạy cảm trong payload; không chấp nhận
alg: none; luôn kiểmexp,iss,aud.
User bấm logout thì JWT có tự hết hiệu lực không?
Không. Đây là trade-off lớn nhất của JWT. Server chỉ kiểm chữ ký và exp, không tra DB, nên token còn hạn vẫn được chấp nhận. Các cách xử lý:
| Chiến lược | Ưu | Nhược |
|---|---|---|
| Access token ngắn hạn | Đơn giản, dễ scale | Còn sống tới khi hết hạn |
| Revoke refresh token | Chặn cấp token mới | Access token hiện tại vẫn chạy |
Denylist jti trong Redis (TTL = thời gian còn lại) | Revoke ngay | Thêm 1 lookup Redis/request |
| Token version theo user | Thu hồi mọi token của user | Phải tra version hiện tại |
| Opaque token + introspection | Server kiểm soát hoàn toàn | Mất tính stateless |
Denylist muốn có hiệu lực phải nằm trong bước validate, ví dụ một OAuth2TokenValidator<Jwt> tùy chỉnh gắn vào JwtDecoder:
OAuth2TokenValidator<Jwt> notRevoked = jwt ->
Boolean.TRUE.equals(redis.hasKey("revoked:jwt:" + jwt.getId()))
? OAuth2TokenValidatorResult.failure(new OAuth2Error("invalid_token", "Token revoked", null))
: OAuth2TokenValidatorResult.success();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer("shop-app"), notRevoked));
JWT có role = ADMIN, sau đó admin bị hạ xuống USER trong database. Token cũ còn quyền ADMIN không?
Còn, cho tới khi hết hạn, nếu server chỉ tin claim trong token. JWT là snapshot quyền tại thời điểm phát hành. Đây chính là lý do access token phải ngắn hạn. Với thao tác nhạy cảm (xóa user, chuyển tiền), có thể kiểm lại quyền từ DB, hoặc dùng token version để vô hiệu hóa token cũ khi quyền thay đổi.
Vậy JWT có mã hóa không? Sao tôi dán lên jwt.io đọc được hết.
Đúng như bạn thấy: JWT thông thường là JWS (signed), chỉ ký, không mã hóa. Chữ ký bảo đảm toàn vẹn và nguồn gốc, không bảo đảm bí mật. Muốn mã hóa payload thì dùng JWE, nhưng hiếm khi cần; cách đúng là đừng đặt dữ liệu nhạy cảm vào token và luôn đi qua HTTPS. Cũng đừng dán token production lên trang web bên ngoài.
Phỏng vấn hỏi về JWT thì trả lời sao?
Q1. What is JWT and why is it stateless?
🇻🇳 Tiếng Việt:
JWT là định dạng token gọn, URL-safe, gồm header, payload (claims) và signature. Server verify chữ ký và các claim như
exp,issmà không cần tra session, nên gọi là stateless và dễ scale ngang. Payload chỉ encode, không mã hóa.
🇬🇧 English:
JWT is a compact, URL-safe token made of a header, a payload of claims and a signature. The server verifies the signature and claims like
expandisswithout a session lookup, so it is stateless and scales horizontally. The payload is only encoded, not encrypted.
Q2. Spring Security validate JWT thế nào? Có nên tự viết filter?
🇻🇳 Tiếng Việt:
Với
oauth2ResourceServer().jwt(),BearerTokenAuthenticationFilterlấy token,JwtAuthenticationProviderdùngJwtDecoderverify chữ ký,exp,iss, rồiJwtAuthenticationConvertermap claim thành authorities và đặt vào SecurityContext. Em ưu tiên cách này hơn tự viếtOncePerRequestFilter, vì đã chuẩn hóa validate và lỗi, và đổi sang Keycloak chỉ cần đổiissuer-uri.
🇬🇧 English:
With
oauth2ResourceServer().jwt(),BearerTokenAuthenticationFilterextracts the token,JwtAuthenticationProvideruses aJwtDecoderto verify the signature,expandiss, andJwtAuthenticationConvertermaps claims to authorities in the security context. I prefer it over a customOncePerRequestFilterbecause validation and error handling are standard, and switching to Keycloak only means changing theissuer-uri.
Q3. Can JWT be revoked immediately?
🇻🇳 Tiếng Việt:
Không tự động. Token hợp lệ tới khi hết hạn. Em dùng access token ngắn hạn kết hợp refresh token có thể revoke; nếu cần revoke ngay thì denylist
jtitrong Redis với TTL bằng thời gian còn lại, hoặc token version, chấp nhận thêm một lookup.
🇬🇧 English:
Not automatically; a signed JWT stays valid until it expires. I use short-lived access tokens with revocable refresh tokens. If immediate revocation is required, I add a Redis denylist of
jtivalues with a TTL equal to the remaining lifetime, or a token version, accepting one extra lookup per request.
Q4. Where should a web client store the token?
🇻🇳 Tiếng Việt:
Không có chỗ hoàn hảo.
localStoragedễ bị XSS đọc. CookieHttpOnly; Secure; SameSitechống JS đọc nhưng phải tính CSRF. Với SPA, em giữ access token trong memory và refresh token trong HttpOnly cookie, hoặc dùng BFF. Quyết định theo loại client và threat model.
🇬🇧 English:
There is no perfect place.
localStorageis readable by XSS. AnHttpOnly; Secure; SameSitecookie hides it from JavaScript but brings CSRF back into scope. For SPAs, I keep the access token in memory and the refresh token in an HttpOnly cookie, or use a BFF. It depends on the client type and the threat model.
Cách nhớ ngắn gọn
- JWT = header.payload.signature; ký chứ không mã hóa.
- Spring:
oauth2ResourceServer().jwt()+JwtDecoder+ converter cho roles. - HS256 một secret; RS256 private ký, public verify (microservices).
- Stateless = không revoke được ngay → token ngắn hạn + refresh token + denylist khi cần.
- Bearer header → tắt CSRF được; JWT trong cookie → không.
Tự kiểm tra
Ai có token có đọc được payload không?
Có, payload chỉ Base64URL encode. Vì vậy không đặt dữ liệu nhạy cảm vào đó.
Dùng oauth2ResourceServer mà hasRole("ADMIN") luôn 403 dù token có claim roles. Vì sao?
Mặc định Spring chỉ map scope/scp thành SCOPE_*. Cần JwtAuthenticationConverter đọc claim roles.
Logout xong, access token cũ còn dùng được không nếu không có denylist?
Còn, tới khi hết hạn.
Vì sao microservices thường dùng RS256 thay HS256?
Service chỉ cần public key để verify; không service nào giữ khóa ký nên không tự phát token giả được.
Gọi API với token sai chữ ký nhận mã gì?
401, kèm WWW-Authenticate: Bearer error="invalid_token".