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.
Trục thời gian đầy đủ của một cặp token — từ login, qua lúc access token hết hạn, tới lúc refresh xoay token — đặt ở section 4 bên dưới, sau khi đã có đủ code issue và refresh để đọc nó.
1. 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Ũ:

Nếu chỉ đợi R1 tự hết hạn thì user thật và kẻ trộm cùng mint access token suốt 30 ngày mà server không thấy gì bất thường. Revoke ngay lúc exchange vừa cắt đường đó, vừa dựng ra một cái chuông: lần dùng thứ hai là báo động.
5. 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.

Đâ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.
- Q2Tại sao phải dùng
secret.getBytes(StandardCharsets.UTF_8)thay vìsecret.getBytes()khi tạoSecretKey? Hậu quả nếu sai? - 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?
- 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?
- 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.
Bài tiếp theo: Tổng kết module
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
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