Access token + refresh token: rotation, logout và revocation
Tiếp đi: access token và refresh token.
Ở bài trước, shop-app đã có JWT authentication. Bây giờ đến một bài toán rất thực tế, cũng là câu hỏi hay gặp khi phỏng vấn:
- Access token hết hạn sau 15 phút. Làm sao user dùng tiếp mà không phải đăng nhập lại?
- User bấm Logout, tại sao access token cũ vẫn chạy được? Xử lý thế nào?
1. Bài toán: access token hết hạn
John đăng nhập shop-app lúc 10:00. Server trả về:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"expiresIn": 900
}
10:00 10:07 10:15 10:20
|============|============|------------|
Login Valid Token expires Expired
10:16, John gọi:
GET /api/orders
Authorization: Bearer <expired-access-token>
HTTP/1.1 401 Unauthorized
Không có cơ chế refresh thì John phải login lại. Trải nghiệm tệ.
2. Vấn đề
- Cho access token sống lâu (7 ngày) thì bị lộ là attacker dùng được 7 ngày.
- Cho sống ngắn (15 phút) thì user phải nhập password liên tục.
- JWT là stateless: server chỉ kiểm tra chữ ký và
exp, không biết user đã logout.
3. Giải pháp: access token ngắn + refresh token dài
Khi login thành công, server cấp hai token:
{
"accessToken": "eyJhbGciOiJIUzI1NiJ9...",
"refreshToken": "random-secret-token",
"tokenType": "Bearer",
"expiresIn": 900
}
| Access Token | Refresh Token | |
|---|---|---|
| Mục đích | Gọi API | Xin access token mới |
| Thời hạn ví dụ | 15 phút | 7 ngày |
| Gửi đến | Business API | Chỉ /api/auth/refresh |
| Định dạng | JWT | Opaque random token |
| Lưu ở server | Không bắt buộc | Lưu hash trong DB |
| Bị đánh cắp | Gọi API đến khi hết hạn | Tạo được token mới, nguy hiểm hơn |
Access token dùng JWT, refresh token dùng chuỗi random opaque: dễ revoke vì server giữ trạng thái.
4. Code từng bước
Bước 1: bảng refresh_tokens
CREATE TABLE refresh_tokens (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES app_users(id),
token_hash VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
revoked BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL
);
Một user login trên nhiều thiết bị thì có nhiều refresh token. Production có thể thêm family_id, device_id, replaced_by, revoked_at để quản lý phiên và phát hiện reuse.
Bước 2: entity + repository có lock
@Entity
@Table(name = "refresh_tokens")
public class RefreshToken {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id", nullable = false)
private AppUser user;
@Column(name = "token_hash", nullable = false, unique = true)
private String tokenHash;
private Instant expiresAt;
private boolean revoked;
private Instant createdAt;
// getters, setters
}
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("""
SELECT rt FROM RefreshToken rt JOIN FETCH rt.user
WHERE rt.tokenHash = :hash
""")
Optional<RefreshToken> findByHashForUpdate(@Param("hash") String hash);
}
Bước 3: sinh token an toàn, chỉ lưu hash
@Service
public class RefreshTokenService {
private final RefreshTokenRepository repository;
private final SecureRandom secureRandom = new SecureRandom();
public RefreshTokenService(RefreshTokenRepository repository) {
this.repository = repository;
}
public String generateRawToken() {
byte[] bytes = new byte[32];
secureRandom.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
public String hashToken(String rawToken) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(
digest.digest(rawToken.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException(e);
}
}
@Transactional
public String createRefreshToken(AppUser user) {
String raw = generateRawToken();
RefreshToken token = new RefreshToken();
token.setUser(user);
token.setTokenHash(hashToken(raw));
token.setCreatedAt(Instant.now());
token.setExpiresAt(Instant.now().plus(7, ChronoUnit.DAYS));
repository.save(token);
return raw; // chỉ trả raw cho client, DB giữ hash
}
}
Vì sao hash? DB bị lộ thì attacker không dùng được giá trị hash làm refresh token. Token random entropy cao nên SHA-256 là đủ, khác password do người đặt (cần BCrypt/Argon2, xem bài 4.4.9).
Bước 4: login trả cả hai token
@Transactional
public TokenResponse login(LoginRequest request) {
Authentication auth = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
AppUser user = userRepository.findByUsername(auth.getName()).orElseThrow();
String accessToken = jwtService.generateToken(auth);
String refreshToken = refreshTokenService.createRefreshToken(user);
return new TokenResponse(accessToken, refreshToken, "Bearer", 900);
}
public record TokenResponse(String accessToken, String refreshToken,
String tokenType, long expiresIn) {}
Bước 5: refresh token rotation
Mỗi lần refresh: kiểm tra token hiện tại, revoke nó, tạo refresh token mới, tạo access token mới, trả cả hai.
@Transactional
public RefreshResult rotate(String rawToken) {
RefreshToken current = repository.findByHashForUpdate(hashToken(rawToken))
.orElseThrow(() -> new BadCredentialsException("Invalid refresh token"));
if (current.isRevoked()) {
// token đã dùng rồi mà vẫn bị gửi lại => nghi reuse
throw new BadCredentialsException("Refresh token already used");
}
if (current.getExpiresAt().isBefore(Instant.now())) {
throw new BadCredentialsException("Refresh token expired");
}
current.setRevoked(true);
String newRaw = generateRawToken();
RefreshToken replacement = new RefreshToken();
replacement.setUser(current.getUser());
replacement.setTokenHash(hashToken(newRaw));
replacement.setCreatedAt(Instant.now());
replacement.setExpiresAt(Instant.now().plus(7, ChronoUnit.DAYS));
repository.save(replacement);
return new RefreshResult(current.getUser().getId(), newRaw);
}
public record RefreshResult(Long userId, String refreshToken) {}
Bước 6: test bằng curl
# login
curl -X POST localhost:8080/api/auth/login -H "Content-Type: application/json" \
-d '{"username":"john","password":"<your-password>"}'
# refresh với token A => nhận access mới + refresh B
curl -X POST localhost:8080/api/auth/refresh -H "Content-Type: application/json" \
-d '{"refreshToken":"<A>"}'
# gửi lại A lần nữa => 401 "Refresh token already used"
5. Cách hoạt động
Client shop-app
| 1. POST /login --------------------> |
| <-------------- 2. access + refresh A |
| 3. GET /orders + access ------------> |
| <---------------- 4. 401 (expired) |
| 5. POST /refresh (A) ---------------> | revoke A, tạo B
| <------------ 6. access mới + refresh B|
| (retry GET /orders với token mới) |
Vì sao cần @Transactional + lock? Hai request cùng gửi token A:
Request 1 Request 2
Find A Find A
Lock acquired Waiting for lock
Revoke A, create B
Commit
Lock acquired
A is revoked => reject
Không khóa thì cả hai cùng đọc revoked = false và cùng tạo token mới. Đây chính là race condition đã học ở phần concurrency.
6. So sánh các chiến lược logout
| Chiến lược | Ưu điểm | Nhược điểm |
|---|---|---|
| Access token ngắn hạn | Đơn giản, dễ scale | Token còn chạy đến khi hết hạn |
| Revoke refresh token | Ngăn cấp token mới | Không giết access token hiện tại |
Redis denylist (jti) | Revoke access token ngay | Thêm Redis lookup mỗi request |
| Token version theo user | Thu hồi mọi phiên của user | Phải tra version hiện tại |
| Opaque access token | Server kiểm soát hoàn toàn | Cần lookup / introspection |
7. Khi nào nên dùng / không nên dùng
- Nên: SPA, mobile app, REST API stateless cần phiên dài mà access token vẫn ngắn.
- Nên thêm denylist: banking, trang quản trị, nơi logout/khóa tài khoản phải có hiệu lực ngay.
- Không cần tự làm: khi đã có Keycloak/Auth0 cấp token (bài 4.4.7, 4.4.8), dùng refresh của IdP.
- Không nên: gửi refresh token đến mọi API, hoặc lưu refresh token plain text trong DB.
Vậy logout xong mà access token cũ vẫn gọi API được à?
Đúng rồi, đây là phần nhiều người hiểu nhầm. Giả sử access token còn 10 phút, refresh token còn 7 ngày. User gọi POST /api/auth/logout, server revoke refresh token. Kết quả:
- Refresh token không còn tạo được access token mới.
- Nhưng access token cũ vẫn chạy trong 10 phút còn lại, vì resource server chỉ kiểm tra chữ ký và
exp.
Nếu yêu cầu access token chết ngay, dùng Redis denylist. Khi phát JWT thì thêm jti:
JwtClaimsSet claims = JwtClaimsSet.builder()
.id(UUID.randomUUID().toString()) // jti
.subject(authentication.getName())
.issuedAt(now)
.expiresAt(now.plus(15, ChronoUnit.MINUTES))
.build();
Khi logout, lưu jti vào Redis với TTL bằng thời gian sống còn lại:
public void revokeAccessToken(Jwt jwt) {
Duration ttl = Duration.between(Instant.now(), jwt.getExpiresAt());
if (ttl.isPositive()) {
redisTemplate.opsForValue().set("revoked:jwt:" + jwt.getId(), "1", ttl);
}
}
Rồi phải cắm bước kiểm tra vào quá trình validate JWT, ví dụ một OAuth2TokenValidator<Jwt>:
Verify signature + exp
|
v
Check Redis "revoked:jwt:{jti}"
+-- exists --> reject (401)
+-- missing --> continue
Trade-off: mỗi request thêm một lần gọi Redis, thêm một dependency. Phải quyết định khi Redis down thì fail-open hay fail-closed.
Còn token thì lưu ở đâu bên frontend? localStorage được không?
Bạn hỏi đúng chỗ quan trọng. Không có cách tối ưu cho mọi app, phải dựa vào loại client và threat model:
| Nơi lưu | Rủi ro chính | Ghi chú |
|---|---|---|
localStorage | XSS đọc được token | Đơn giản nhưng rủi ro nhất |
| Memory (biến JS) | Mất khi reload trang | Hợp cho access token ngắn hạn |
Cookie HttpOnly; Secure; SameSite | Browser tự gửi => phải tính CSRF | Hợp cho refresh token, giới hạn Path=/api/auth |
| BFF (Backend-for-Frontend) | Thêm một tầng server | Token không bao giờ xuống browser |
Thiết kế phổ biến cho SPA: access token giữ trong memory, refresh token trong cookie HttpOnly:
Set-Cookie: refresh_token=...; HttpOnly; Secure; SameSite=Strict; Path=/api/auth
Lưu ý: HttpOnly chỉ ngăn JS đọc cookie, không chặn được script XSS gửi request thay user. CSRF và XSS sẽ học kỹ ở bài 4.4.9.
Phỏng vấn hỏi refresh token với logout JWT thì trả lời sao?
Q1. Vì sao cần refresh token?
🇻🇳 Tiếng Việt:
Để access token có thể sống ngắn (vài phút), giảm thiệt hại khi bị lộ, mà user vẫn không phải nhập lại password. Khi access token hết hạn, client gửi refresh token đến endpoint riêng để lấy token mới. Refresh token là chuỗi random, server lưu hash nên có thể revoke.
🇬🇧 English:
Refresh tokens let us keep access tokens short-lived, which limits the damage if one leaks, while users do not have to re-enter credentials. When the access token expires, the client calls a dedicated refresh endpoint. I store only a hash of the opaque refresh token in the database, so it can be revoked.
Q2. Refresh token rotation và reuse detection là gì?
🇻🇳 Tiếng Việt:
Mỗi lần refresh, token cũ bị revoke và cấp token mới. Nếu token đã revoke bị gửi lại, đó là dấu hiệu bị đánh cắp, server có thể revoke cả token family. Em dùng transaction và pessimistic lock để một token chỉ được dùng đúng một lần khi có request đồng thời.
🇬🇧 English:
With rotation, each refresh invalidates the old refresh token and issues a new one. If a revoked token is presented again, it signals possible theft, and the server can revoke the whole token family. I use a transaction with a pessimistic lock so concurrent requests cannot consume the same token twice.
Q3. Vì sao logout với JWT khó?
🇻🇳 Tiếng Việt:
JWT tự chứa thông tin và đã ký, resource server chỉ kiểm tra chữ ký và hạn, nên token còn hiệu lực đến khi hết hạn dù user đã logout. Cách cân bằng là access token ngắn + revoke refresh token; cần chặn ngay thì dùng Redis denylist theo
jtivới TTL bằng thời gian còn lại.
🇬🇧 English:
A signed, self-contained JWT stays valid until it expires because the resource server only checks the signature and expiry. The usual balance is short-lived access tokens plus revoking the refresh token. If immediate revocation is required, I add a Redis denylist keyed by
jtiwith a TTL equal to the token's remaining lifetime.
Cách nhớ ngắn gọn
- Access ngắn (15 phút, JWT), refresh dài (7 ngày, opaque, lưu hash).
- Rotation: dùng một lần, A đổi lấy B; A bị dùng lại = báo động.
- Logout = revoke refresh; muốn giết access ngay = denylist
jti. - SPA: access trong memory, refresh trong cookie HttpOnly.
Tự kiểm tra
Vì sao lưu hash của refresh token chứ không lưu nguyên giá trị?
DB bị lộ thì attacker không dùng được hash làm token. Token random entropy cao nên SHA-256 là đủ.
Không có lock thì rotation gặp lỗi gì?
Hai request đồng thời cùng đọc revoked = false, cùng tạo token mới: một refresh token sinh ra hai phiên hợp lệ.
User logout, server revoke refresh token nhưng không có denylist. Attacker dùng access token cũ thì sao?
Request vẫn thành công cho đến khi access token hết hạn.
TTL của key denylist trong Redis nên đặt bao nhiêu?
Bằng thời gian sống còn lại của JWT (exp - now). Sau đó token tự hết hạn nên không cần giữ key.
Refresh token trong cookie HttpOnly có cần lo CSRF không?
Có, vì browser tự gửi cookie. Giới hạn Path, dùng SameSite và kiểm tra Origin/CSRF token cho endpoint refresh.