Issue, refresh và revoke JWT — vòng đời token trong production
NimbusJwtEncoder issue access token ngắn + refresh token dài ở login endpoint. Refresh endpoint xoay token. Ba chiến lược revocation: short expiry, Redis denylist, DB refresh store. Pitfall secret từ env, không lưu sensitive data trong payload.
TL;DR: Login endpoint gọi NimbusJwtEncoder để issue access token (1h) và refresh token (30 ngày). Khi access token hết hạn, client gửi refresh token — server validate, revoke token cũ, issue cặp mới. Revocation là bài toán khó của JWT stateless: ba chiến lược từ đơn giản đến chặt chẽ là short expiry (5-15 phút), Redis denylist, và DB refresh store. Secret lấy từ biến môi trường (@Value("${app.jwt.secret}")), không bao giờ hardcode; payload không chứa dữ liệu nhạy cảm vì base64 đọc được bởi bất kỳ ai cầm token.
Bài 03 — JWT structure & validation đã giải thích cấu trúc header.payload.signature và cách NimbusJwtDecoder validate. Bài này tập trung vào phía issue: làm sao tạo token đúng cách, và điều gì xảy ra khi token hết hạn hoặc bị revoke.
Vì sao cần access token ngắn + refresh token dài?
JWT stateless là điểm mạnh khi scale: server không cần session store, mọi API node đều validate được token bằng secret/public key. Nhưng stateless cũng là điểm yếu khi cần revoke: một token đã issue thì valid đến exp — server không có cách "thu hồi" nó trực tiếp.
Thiết kế access token ngắn + refresh token dài là cách giải quyết trade-off này:
| Token | Thời sống | Mục đích |
|---|---|---|
| Access token | 15 phút - 1 giờ | Gọi API. Ngắn để giới hạn cửa sổ rủi ro nếu bị leak |
| Refresh token | 7 - 30 ngày | Lấy access token mới. Lưu DB để revoke được |
Lý do cần cả hai:
- Nếu chỉ dùng access token dài (24h+): khi cần revoke (đổi mật khẩu, phát hiện tài khoản bị compromise), phải đợi đến tận ngày hôm sau. Cửa sổ rủi ro 24h.
- Nếu chỉ dùng access token ngắn không có refresh: user phải đăng nhập lại mỗi 15 phút — trải nghiệm tệ.
- Access token ngắn + refresh token dài lưu DB: access token hết hạn sau 15 phút (cửa sổ rủi ro nhỏ), refresh token revoke được bằng cách xoá DB row.
sequenceDiagram
participant C as Client
participant A as Auth API
participant DB as Database
C->>A: POST /api/auth/login (email + password)
A->>DB: load UserDetails, verify password
A-->>C: {accessToken (1h), refreshToken (30d)}
Note over C,A: 55 phut sau -- access sap het han
C->>A: GET /api/projects (Bearer accessToken)
A-->>C: 200 OK
Note over C,A: Sau 1h -- access het han
C->>A: GET /api/projects (expired token)
A-->>C: 401 Unauthorized
C->>A: POST /api/auth/refresh {refreshToken}
A->>DB: validate refresh token, revoke cu
A->>DB: save refresh token moi
A-->>C: {new accessToken, new refreshToken}
C->>A: GET /api/projects (new accessToken)
A-->>C: 200 OK1. Cấu hình JwtEncoder — NimbusJwtEncoder
Spring Security 6 cung cấp JwtEncoder interface để tạo (sign) JWT. Implementation chuẩn là NimbusJwtEncoder từ thư viện Nimbus JOSE. Để dùng JwtEncoder, cần khai báo bean cấu hình key:
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.jwt.JwtEncoder;
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
import com.nimbusds.jose.jwk.OctetSequenceKey;
import com.nimbusds.jose.jwk.JWKSet;
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
import com.nimbusds.jose.proc.SecurityContext;
import javax.crypto.SecretKey;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
@Configuration
public class JwtEncoderConfig {
@Value("${app.jwt.secret}")
private String secret; // from env, never hardcode
@Bean
public JwtEncoder jwtEncoder() {
SecretKey key = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
OctetSequenceKey jwk = new OctetSequenceKey.Builder(key).build();
JWKSource<SecurityContext> jwkSource =
new ImmutableJWKSet<>(new JWKSet(jwk));
return new NimbusJwtEncoder(jwkSource);
}
}
Lưu ý secret.getBytes(StandardCharsets.UTF_8) — charset tường minh là bắt buộc, lý do ở pitfall 6.5.
Secret lấy từ application.yml hoặc biến môi trường:
# application.yml
app:
jwt:
secret: ${JWT_SECRET} # set trong env hoặc secret manager
Tạo secret 256-bit ngẫu nhiên (bắt buộc cho HS256):
# Tao key 32 bytes = 256 bit, encode base64
openssl rand -base64 32
# Vi du output: G3wKf9XzN2pQ8vL6tH4mR1yE5sD7uA0bC9wY3jK7iN8=
2. JwtIssuer — issue access token và refresh token
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.jwt.*;
import org.springframework.stereotype.Service;
import lombok.RequiredArgsConstructor;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class JwtIssuer {
private final JwtEncoder encoder;
public String issueAccessToken(UserDetails user) {
Instant now = Instant.now();
List<String> roles = user.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.map(a -> a.replaceFirst("^ROLE_", "")) // strip prefix: ROLE_USER -> USER
.toList();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("olhub.org")
.subject(user.getUsername())
.audience(List.of("olhub-api"))
.issuedAt(now)
.expiresAt(now.plus(Duration.ofHours(1)))
.id(UUID.randomUUID().toString()) // jti -- for revocation
.claim("roles", roles)
.build();
return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
public String issueRefreshToken(UserDetails user) {
Instant now = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("olhub.org")
.subject(user.getUsername())
.issuedAt(now)
.expiresAt(now.plus(Duration.ofDays(30)))
.id(UUID.randomUUID().toString())
.claim("type", "refresh") // marker -- filter request dung access token lam refresh
.build();
return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
}
}
Hai điểm quan trọng trong code trên:
jti(id) — UUID ngẫu nhiên mỗi token. Đây là định danh duy nhất dùng để revoke token cụ thể trong Redis denylist hoặc DB.claim("type", "refresh")— marker phân biệt refresh token với access token. Khi nhận refresh request, server kiểm tra claim này để ngăn client dùng access token thay cho refresh token.
3. Login endpoint — issue cả hai token
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthenticationManager authManager;
private final JwtIssuer jwtIssuer;
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginRequest req) {
Authentication auth = authManager.authenticate(
new UsernamePasswordAuthenticationToken(req.email(), req.password())
);
UserDetails user = (UserDetails) auth.getPrincipal();
String accessToken = jwtIssuer.issueAccessToken(user);
String refreshToken = jwtIssuer.issueRefreshToken(user);
return new TokenResponse(accessToken, refreshToken, "Bearer", 3600L);
}
}
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank String password) {}
public record TokenResponse(
String accessToken,
String refreshToken,
String tokenType,
Long expiresIn) {}
Luồng hoạt động: authManager.authenticate() gọi UserDetailsService.loadUserByUsername() rồi PasswordEncoder.matches(). Nếu sai credentials, manager ném BadCredentialsException — Spring Security tự convert thành 401. Nếu đúng, manager trả về Authentication đã xác thực và controller issue hai token.
Mặc định Spring Security không expose AuthenticationManager như một bean. Phải khai báo tường minh trong SecurityConfig:
@Bean
public AuthenticationManager authManager(AuthenticationConfiguration cfg) throws Exception {
return cfg.getAuthenticationManager();
}
4. Refresh endpoint — xoay token
Refresh token rotation: mỗi lần refresh, revoke token cũ và issue cặp token mới. Điều này ngăn refresh token bị dùng lại sau khi đã exchange (Refresh Token Rotation pattern).
@PostMapping("/refresh")
public TokenResponse refresh(@Valid @RequestBody RefreshRequest req) {
// validate signature + expiry -- throw JwtException neu sai
Jwt jwt = refreshTokenDecoder.decode(req.refreshToken());
// Guard: chi chap nhan refresh token, khong chap nhan access token bi gui nham
if (!"refresh".equals(jwt.getClaim("type"))) {
throw new InvalidBearerTokenException("Not a refresh token");
}
String username = jwt.getSubject();
UserDetails user = userDetailsService.loadUserByUsername(username);
// Revoke token cu (DB store -- xem section 5)
refreshTokenStore.revoke(jwt.getId());
// Issue cap moi
String newAccess = jwtIssuer.issueAccessToken(user);
String newRefresh = jwtIssuer.issueRefreshToken(user);
// Luu refresh token moi vao DB
refreshTokenStore.save(username, jwt.getId(), jwt.getExpiresAt());
return new TokenResponse(newAccess, newRefresh, "Bearer", 3600L);
}
public record RefreshRequest(@NotBlank String refreshToken) {}
Refresh token sống 30 ngày. Dựa vào expiry để nó "tự hết hạn" nghĩa là chấp nhận attacker dùng được 30 ngày. Revocation phải nằm server-side (DB store hoặc Redis denylist — section 5), và rotation phải revoke token cũ ngay khi exchange.
Attack chain nếu không revoke: attacker đánh cắp refresh token (XSS, log leak, thiết bị mất) rồi dùng song song với user thật. Cả hai cùng exchange một refresh token, mỗi bên liên tục mint access token mới — server không phân biệt được ai hợp lệ, suốt 30 ngày.
Rotation + revoke phá vỡ kịch bản đó và tạo ra tín hiệu phát hiện: một token đã revoke mà bị dùng lần nữa nghĩa là có hai bên cùng cầm nó — revoke toàn bộ chain của user (revokeAllForUser) và bắt đăng nhập lại. Đây là Refresh Token Rotation theo RFC 6749 §6 và OAuth 2.0 Security BCP.
Vòng đời cặp token trên trục thời gian — chú ý bước cuối, dùng lại refresh token CŨ:
sequenceDiagram
participant C as Client
participant S as Auth API
Note over C,S: 10:00 login -- access A1 (exp 10:15) + refresh R1
C->>S: 10:05 GET /api/projects (Bearer A1)
S-->>C: 200 OK
C->>S: 10:16 GET /api/projects (A1 het han)
S-->>C: 401
C->>S: POST /refresh (R1)
S-->>C: cap moi A2 + R2 (R1 bi revoke trong DB)
C->>S: POST /refresh (R1 lan 2 -- token cu)
S-->>C: 401 -- phat hien reuse, revoke ca chain5. Revocation — ba chiến lược
JWT stateless không có session để xoá. Đây là giới hạn cơ bản của thiết kế. Ba chiến lược dưới đây giải quyết ở mức độ khác nhau:
5.1 Short expiry (đơn giản nhất)
Đặt access token sống 5-15 phút. Khi cần revoke (user logout, đổi mật khẩu), không làm gì với access token — chỉ cần revoke refresh token trong DB. Access token tự hết hạn sau tối đa 15 phút.
.expiresAt(now.plus(Duration.ofMinutes(15))) // access token
Trade-off: cửa sổ 5-15 phút sau khi revoke, user hoặc kẻ tấn công vẫn dùng được access token cũ. Chấp nhận được với đại đa số ứng dụng. Banking và healthcare cần real-time thì xem strategy 5.2.
5.2 Redis denylist (revoke real-time)
Khi cần revoke access token, ghi jti của token vào Redis với TTL bằng thời gian còn lại đến exp. Mỗi request, filter kiểm tra denylist trước khi cho qua.
@Service
@RequiredArgsConstructor
public class TokenDenylistService {
private final StringRedisTemplate redis;
public void revoke(String jti, Instant expiresAt) {
Duration remaining = Duration.between(Instant.now(), expiresAt);
if (!remaining.isNegative()) {
redis.opsForValue().set("revoked:" + jti, "1", remaining);
}
}
public boolean isRevoked(String jti) {
return Boolean.TRUE.equals(redis.hasKey("revoked:" + jti));
}
}
@Component
@RequiredArgsConstructor
public class JwtRevocationFilter extends OncePerRequestFilter {
private final TokenDenylistService denylist;
private final JwtDecoder jwtDecoder;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain)
throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
try {
Jwt jwt = jwtDecoder.decode(token);
if (denylist.isRevoked(jwt.getId())) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
return;
}
} catch (JwtException ignored) {
// JwtAuthenticationProvider xu ly loi nay sau trong chain
}
}
chain.doFilter(request, response);
}
}
Trade-off: mỗi request thêm một Redis lookup (~1ms). Dataset nhỏ (chỉ token bị revoke, tự xoá khi hết TTL). Phá vỡ một phần tính stateless nhưng Redis lookup rẻ hơn nhiều so với DB query.
5.3 DB refresh token store (khuyến nghị cho production)
Lưu refresh token vào bảng DB. Revoke = đánh dấu cột revoked = true. Mỗi lần refresh, kiểm tra DB trước khi issue token mới.
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id
private String id; // jti
private String username;
private Instant issuedAt;
private Instant expiresAt;
private boolean revoked;
}
@Service
@RequiredArgsConstructor
public class RefreshTokenStore {
private final RefreshTokenRepository repo;
public void save(String username, String jti, Instant expiresAt) {
RefreshToken token = new RefreshToken();
token.setId(jti);
token.setUsername(username);
token.setIssuedAt(Instant.now());
token.setExpiresAt(expiresAt);
token.setRevoked(false);
repo.save(token);
}
public boolean isValid(String jti) {
return repo.findById(jti)
.filter(t -> !t.isRevoked() && t.getExpiresAt().isAfter(Instant.now()))
.isPresent();
}
public void revoke(String jti) {
repo.findById(jti).ifPresent(t -> {
t.setRevoked(true);
repo.save(t);
});
}
public void revokeAllForUser(String username) {
// Dung khi doi mat khau hoac phat hien bi tan cong
repo.revokeAllByUsername(username);
}
}
Dọn dẹp token hết hạn tránh bảng phình:
@Scheduled(cron = "0 0 3 * * *") // 3am moi ngay
public void cleanupExpiredTokens() {
int deleted = repo.deleteByExpiresAtBefore(Instant.now());
log.info("Cleaned up {} expired refresh tokens", deleted);
}
Pattern khuyến nghị 2026:
- Access token: 15 phút, JWT stateless, không revoke (wait expiry).
- Refresh token: 30 ngày, lưu DB, revoke khi logout hoặc đổi mật khẩu.
- Mass invalidation (tài khoản bị compromise):
revokeAllForUser()— tất cả session của user đó bị khoá sau lần refresh tiếp theo.
Cơ chế bên dưới — tại sao stateless revocation khó
Vấn đề cốt lõi: JWT là self-contained và bearer token. "Self-contained" có nghĩa mọi thông tin cần để verify (subject, roles, expiry) đều nằm trong token — server không cần tra DB. "Bearer" (RFC 6750) có nghĩa ai cầm token thì có quyền — không có proof-of-possession.
Hai tính chất này cùng nhau tạo ra vấn đề: server validate token chỉ dựa vào signature và exp — không biết token đó đã bị user "logout" chưa. Điều này khác hẳn session-based auth nơi server cầm session ID trong DB và có thể xoá row bất kỳ lúc nào.
flowchart LR
A["Token issue: jti=abc, exp=T+1h"]
B["User logout at T+30min"]
C["Attacker dung token luc T+45min"]
D["Server: signature OK, exp chua het -> 200 OK"]
A --> B
B --> C
C --> DĐây là lý do access token ngắn là giải pháp "đủ tốt" cho hầu hết use case: cửa sổ rủi ro sau logout tối đa bằng thời gian còn lại của access token (5-15 phút). Redis denylist và DB store là hai cách "inject" trạng thái stateful vào JWT stateless để đổi lấy real-time revocation.
6. Pitfall — những lỗi phổ biến
6.1 Hardcode secret
// SAI -- secret trong source code = ai clone repo cung co key
private String secret = "my-super-secret-key-for-jwt-signing";
// DUNG -- lay tu bien moi truong
@Value("${app.jwt.secret}")
private String secret;
Secret hardcode trong source control là breach điển hình. Ngay cả khi repo private, secret đó tồn tại mãi trong git history. Dùng biến môi trường, AWS Secrets Manager, hoặc HashiCorp Vault. Rotate định kỳ.
6.2 Sensitive data trong payload
// SAI -- payload la base64 ai cung giai ma duoc
{
"sub": "[email protected]",
"ssn": "123-45-6789",
"creditCard": "4111-1111-1111-1111"
}
// DUNG -- chi luu identifier va role
{
"sub": "[email protected]",
"roles": ["USER"],
"exp": 1713792400
}
Payload JWT chỉ được encode bằng base64url, không encrypt. Bất kỳ ai cầm token đều đọc được payload bằng cách paste vào https://jwt.io. Chỉ lưu identifier (sub) và quyền (roles) — không lưu PII, password hash, hay business data nhạy cảm.
6.3 Access token quá dài
Access token 24h làm mất đi lợi ích của stateless revocation. Nếu cần dùng access token dài, phải có denylist — lúc đó nên cân nhắc lại thiết kế có cần JWT không, hay session-based đơn giản hơn.
6.4 Không set exp
// SAI -- token khong co expiry = permanent breach neu bi leak
JwtClaimsSet.builder()
.subject(user.getUsername())
.claim("roles", roles)
.build();
// DUNG -- luon set expiresAt
JwtClaimsSet.builder()
.subject(user.getUsername())
.expiresAt(now.plus(Duration.ofHours(1)))
.claim("roles", roles)
.build();
Token không có exp valid mãi mãi. Một token bị leak là permanent access. Spring Security's JwtTimestampValidator tự check exp nếu có — nhưng không reject token không có exp.
6.5 getBytes() không charset — token validate fail tuỳ môi trường
// SAI -- platform default charset: Mac dev la UTF-8, container prod co the khac
SecretKey key = new SecretKeySpec(secret.getBytes(), "HmacSHA256");
// DUNG -- charset tuong minh, byte array giong nhau moi moi truong
SecretKey key = new SecretKeySpec(
secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
Token issue trên máy dev chạy bình thường; lên container prod (locale hoặc -Dfile.encoding khác) thì signature validation fail — lỗi rất khó debug vì "code không đổi gì". Encoder và decoder phải cùng dùng StandardCharsets.UTF_8.
Liên hệ các bài khác
- Bài 03 — JWT structure & validation: bài này đã giải thích
header.payload.signature, base64url encoding, vàNimbusJwtDecodervalidate signature + claims. Bài hiện tại là phần bổ sung — phía issue (encoder) và lifecycle (refresh, revoke). - Method security @PreAuthorize: roles được issue vào claim
rolesở bài này sẽ đượcJwtAuthenticationConvertermap thànhROLE_USER/ROLE_ADMIN, từ đó@PreAuthorize("hasRole('ADMIN')")hoạt động được — bài kia giải thích cơ chế AOP proxy phía sau annotation đó. NimbusJwtEncodervàNimbusJwtDecoderdùng thư việnspring-boot-starter-oauth2-resource-server— cùng dependency vớioauth2ResourceServer.jwt()DSL trongSecurityFilterChain. Cấu hình hai bean này là "cặp đôi" phải nhất quán về algorithm và key.
Tóm tắt
- Issue:
NimbusJwtEncoder+JwtClaimsSettạo token vớisub,exp,jti,roles. Secret từ@Value("${app.jwt.secret}"), không hardcode. - Access token ngắn (15 phút - 1 giờ) giảm cửa sổ rủi ro nếu leak. Refresh token dài (30 ngày) lưu DB để revoke được.
- Refresh endpoint validate refresh token, revoke token cũ, issue cặp mới (Refresh Token Rotation).
- Revocation: short expiry (đơn giản, cửa sổ 5-15 phút), Redis denylist (real-time, ~1ms overhead), DB refresh store (khuyến nghị, revoke per session hoặc toàn bộ user).
- Payload không phải secret: base64url đọc được bởi bất kỳ ai — chỉ lưu
sub+ roles + minimum claims. StandardCharsets.UTF_8tường minh tronggetBytes()để key nhất quán giữa môi trường.
Tự kiểm tra
Q1Vì sao access token ngắn (15 phút) giảm rủi ro so với access token 24 giờ, trong khi JWT về nguyên lý không có cách revoke trực tiếp? Trả lời theo cơ chế bên dưới.▸
JWT stateless không thể revoke trực tiếp vì server validate dựa vào signature và exp — không có session store để xoá. Khi token bị leak hoặc user logout, token vẫn valid đến hết exp.
Với access token 24 giờ: nếu token bị đánh cắp lúc 8 giờ sáng, kẻ tấn công có thể dùng đến tận 8 giờ sáng hôm sau — cửa sổ rủi ro 24 giờ. Với 15 phút: tối đa 15 phút. Đây là "soft revocation" bằng thiết kế thay vì cơ chế.
Refresh token dài hơn nhưng lưu DB — revoke được bằng cách đánh dấu revoked = true. Sau khi access token 15 phút hết hạn, client cần refresh và server từ chối nếu refresh token đã bị revoke. Kết quả: user bị logout sau tối đa 15 phút kể từ lúc revoke refresh token.
Nếu cần revoke real-time (banking, healthcare), Redis denylist ghi jti vào key-value store với TTL — mỗi request kiểm tra denylist (~1ms). Đây mới là revocation thật sự, đổi lấy một Redis call per request.
Q2Tại sao phải dùng secret.getBytes(StandardCharsets.UTF_8) thay vì secret.getBytes() khi tạo SecretKey? Hậu quả nếu sai?▸
secret.getBytes(StandardCharsets.UTF_8) thay vì secret.getBytes() khi tạo SecretKey? Hậu quả nếu sai?String.getBytes() không tham số dùng platform default charset — giá trị phụ thuộc vào JVM startup flag -Dfile.encoding và OS locale. Trên macOS dev thường là UTF-8; trong Docker Alpine/container có thể là Latin-1 hoặc khác nếu không set tường minh.
Hậu quả: secret "abc123" với UTF-8 cho byte array khác với Latin-1 khi chuỗi chứa ký tự ngoài ASCII (ví dụ nếu user vô tình đặt secret có dấu). Hai môi trường dùng byte array khác nhau = key khác nhau = token issue ở dev không validate được ở production.
StandardCharsets.UTF_8 là hằng số compile-time, không phụ thuộc JVM config, nhất quán mọi nền tảng. Đây là pattern bắt buộc khi chuyển đổi String sang byte[] dùng làm cryptographic key.
Trong code JwtEncoderConfig: new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256") — cả encoder và decoder phải dùng cùng cách này. Nếu một bên dùng getBytes() platform-default và bên kia dùng UTF-8 tường minh, signature validation sẽ fail ngẫu nhiên tuỳ môi trường.
Q3Refresh Token Rotation là gì? Tại sao cần revoke refresh token cũ ngay lập tức khi client exchange lấy token mới, thay vì để nó tự hết hạn?▸
Refresh Token Rotation: mỗi lần client dùng refresh token để lấy access token mới, server cũng issue refresh token mới và revoke refresh token vừa dùng. Client cập nhật refresh token mới cho lần sau.
Tại sao revoke ngay? Xét kịch bản: refresh token bị đánh cắp. Nếu không revoke, cả user thật và kẻ tấn công đều dùng được cùng refresh token để lấy access token — server không biết ai "hợp lệ". Cả hai có thể hoạt động song song mãi đến khi refresh token hết 30 ngày.
Với rotation: người đầu tiên dùng refresh token thành công sẽ revoke nó. Người thứ hai (dù là user thật hay kẻ tấn công) dùng token cũ sẽ nhận 401. Điều này tạo ra phát hiện: nếu user thật nhận 401 khi dùng refresh token mà họ chưa exchange, có nghĩa token bị đánh cắp và đã được dùng trước. Server có thể revoke toàn bộ session của user đó.
Hạn chế: nếu network bị lỗi sau khi server revoke token cũ nhưng response chưa về đến client, client mất refresh token và phải đăng nhập lại. Cần idempotency key hoặc grace period ngắn (~30 giây) cho edge case này trong production.
Q4So sánh ba chiến lược revocation: short expiry, Redis denylist, và DB refresh store. Khi nào chọn chiến lược nào?▸
Short expiry (5-15 phút): đơn giản nhất, không thêm infra. Revocation không real-time — cửa sổ tối đa bằng thời gian access token còn lại. Phù hợp cho đại đa số ứng dụng CRUD thông thường. Trade-off chấp nhận được khi rủi ro bảo mật không cao.
Redis denylist: real-time revocation cho access token. Thêm Redis dependency và ~1ms overhead mỗi request. Dataset tự dọn dẹp vì key có TTL. Phù hợp khi cần revoke access token ngay lập tức — ví dụ phát hiện tài khoản bị compromise, admin force logout user. Trade-off: pha vỡ một phần stateless, cần Redis HA (nếu Redis down, tùy config có thể block mọi request).
DB refresh store: lưu refresh token vào DB, revoke bằng cột flag. Không real-time cho access token (vẫn phải đợi access hết hạn), nhưng kiểm soát được toàn bộ refresh token. Dọn dẹp bằng scheduled job. Phù hợp cho mọi ứng dụng có Postgres sẵn — không cần Redis riêng. Cho phép "logout all sessions" bằng revokeAllForUser().
Pattern khuyến nghị: kết hợp access token 15 phút (short expiry) + DB refresh store. Refresh token revoke khi logout, đổi mật khẩu, hoặc phát hiện bất thường. Nếu cần revoke access token real-time, thêm Redis denylist chỉ cho access token (dataset nhỏ). Banking/healthcare: Redis denylist bắt buộc. CRUD app: short expiry + DB refresh store đủ.
Q5Payload JWT chứa thông tin gì được và không được? Giải thích vì sao payload không phải "bí mật" dù token được sign bằng HMAC-SHA256.▸
JWT gồm ba phần header.payload.signature, mỗi phần encode bằng base64url — đây là encoding, không phải encryption. Base64url là biến thể URL-safe của base64, hoàn toàn decode được không cần key.
Signature (HMAC-SHA256) chỉ đảm bảo integrity (nội dung không bị sửa) và authenticity (do server có secret mới tạo được) — không đảm bảo confidentiality (không đọc được). Bất kỳ ai cầm token đều đọc được payload bằng cách decode base64url, ví dụ paste vào https://jwt.io.
Được lưu trong payload: identifier (sub = user ID hoặc email), roles (roles: ["USER"]), standard claims (exp, iat, jti), và business metadata không nhạy cảm như tenantId.
Không được lưu: password hash, số CMND/CCCD, số thẻ tín dụng, thông tin y tế, bất kỳ PII có thể gây hại nếu lộ. Nếu cần encrypt payload, dùng JWE (JSON Web Encryption, RFC 7516) — hoàn toàn khác với JWT/JWS thông thường và phức tạp hơn nhiều.
Rule đơn giản: chỉ lưu những gì bạn sẵn sàng cho client (và bất kỳ proxy, log aggregator, developer tool nào trên đường) đọc thấy.
Bài tiếp theo: Tổng kết module
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