Access token + refresh token: rotation, logout và revocation

4 câu hỏi~30 phútTừ khóa: access token, refresh token, rotation, reuse detection, PESSIMISTIC_WRITE, logout, jti, Redis denylist, HttpOnly cookie

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:

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 đề

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 TokenRefresh Token
Mục đíchGọi APIXin access token mới
Thời hạn ví dụ15 phút7 ngày
Gửi đếnBusiness APIChỉ /api/auth/refresh
Định dạngJWTOpaque random token
Lưu ở serverKhông bắt buộcLưu hash trong DB
Bị đánh cắpGọi API đến khi hết hạnTạ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ểmNhược điểm
Access token ngắn hạnĐơn giản, dễ scaleToken còn chạy đến khi hết hạn
Revoke refresh tokenNgăn cấp token mớiKhông giết access token hiện tại
Redis denylist (jti)Revoke access token ngayThêm Redis lookup mỗi request
Token version theo userThu hồi mọi phiên của userPhải tra version hiện tại
Opaque access tokenServer kiểm soát hoàn toànCần lookup / introspection

7. Khi nào nên dùng / không nên dùng

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ả:

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ưuRủi ro chínhGhi chú
localStorageXSS đọc được tokenĐơn giản nhưng rủi ro nhất
Memory (biến JS)Mất khi reload trangHợp cho access token ngắn hạn
Cookie HttpOnly; Secure; SameSiteBrowser tự gửi => phải tính CSRFHợp cho refresh token, giới hạn Path=/api/auth
BFF (Backend-for-Frontend)Thêm một tầng serverToken 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 jti vớ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 jti with a TTL equal to the token's remaining lifetime.

Cách nhớ ngắn gọn


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.