Spring Security & Testing
11/20
Bài 11 / 20~13 phútAuthenticationLộ trình · chặng 18/21Miễn phí… lượt xem

Issue, refresh và revoke JWT — vòng đời token trong production

Tìm hiểu vòng đời token JWT: phát hành Access/Refresh token bằng NimbusJwtEncoder, cơ chế Rotation và 3 chiến lược thu hồi token hiệu quả cho production.

TL;DR: Điểm đăng nhập (/login) sử dụng NimbusJwtEncoder để cấp phát cặp token: Access Token ngắn hạn (15-60 phút) dùng gọi API và Refresh Token dài hạn (7-30 ngày) dùng để xin cấp lại token mới. Khi Access Token hết hạn, client gửi Refresh Token lên endpoint /refresh để áp dụng cơ chế xoay vòng (Refresh Token Rotation): hủy token cũ và cấp phát cặp token hoàn toàn mới. Để giải quyết bài toán thu hồi token trong kiến trúc stateless, ba giải pháp phổ biến gồm: thời hạn sống ngắn, danh sách đen Redis (Denylist) hoặc lưu trữ trạng thái Refresh Token trong cơ sở dữ liệu.

Tại sao hệ thống cần mô hình Access Token ngắn kết hợp Refresh Token dài?

Tính chất phi trạng thái (stateless) của JWT giúp hệ thống mở rộng quy mô dễ dàng nhưng cũng tạo ra rào cản lớn: khi token đã phát hành, máy chủ không thể thu hồi nó trước thời điểm hết hạn (exp).

Mô hình kết hợp giải quyết bài toán đánh đổi này:

Loại tokenThời gian tồn tạiVai trò chính
Access Token15 - 60 phútĐính kèm trong header Authorization để truy cập tài nguyên API; thời gian ngắn giúp thu hẹp cửa sổ rủi ro nếu bị lộ
Refresh Token7 - 30 ngàyLưu an toàn tại client (HttpOnly cookie), dùng để đổi lấy Access Token mới; được lưu DB để có thể thu hồi bất cứ lúc nào

Nếu chỉ dùng một Access Token duy nhất có hạn 24 giờ, khi người dùng đổi mật khẩu hoặc bị lộ máy tính, kẻ tấn công có thể truy cập hệ thống suốt một ngày mà không có cách nào chặn đứng tức thì.

Làm thế nào để cấu hình JwtEncoder phát hành token chuẩn xác?

Spring Security cung cấp interface JwtEncoder để tạo và ký số cho token. Triển khai chuẩn mực bằng NimbusJwtEncoder:

Java
@Configuration
public class JwtEncoderConfig {

    @Value("${app.jwt.secret}")
    private String secret; // Loaded from environment variable

    @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);
    }
}
Bắt buộc chỉ định UTF-8

Luôn sử dụng secret.getBytes(StandardCharsets.UTF_8) để đảm bảo chuỗi byte của khóa bảo mật đồng nhất trên mọi hệ điều hành (tránh lỗi mismatch trên Docker container).

Cơ chế JwtIssuer tạo Access Token và Refresh Token ra sao?

Dịch vụ phát hành token chịu trách nhiệm đóng gói các claims:

Java
@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(auth -> auth.replaceFirst("^ROLE_", ""))
            .toList();

        JwtClaimsSet claims = JwtClaimsSet.builder()
            .issuer("olhub.org")
            .subject(user.getUsername())
            .audience(List.of("olhub-api"))
            .issuedAt(now)
            .expiresAt(now.plus(Duration.ofMinutes(15))) // Short-lived: 15 mins
            .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)))     // Long-lived: 30 days
            .id(UUID.randomUUID().toString())
            .claim("type", "refresh")                    // Guard claim
            .build();

        return encoder.encode(JwtEncoderParameters.from(claims)).getTokenValue();
    }
}

Claim "type": "refresh" đóng vai trò hàng rào bảo vệ, ngăn client dùng nhầm Access Token để gọi endpoint làm mới token.

Endpoint đăng nhập (/login) xử lý việc cấp phát token như thế nào?

Controller tiếp nhận yêu cầu, ủy quyền cho AuthenticationManager kiểm tra mật khẩu rồi phát hành cặp token:

Java
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {

    private final AuthenticationManager authManager;
    private final JwtIssuer jwtIssuer;
    private final RefreshTokenStore tokenStore;

    @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 access = jwtIssuer.issueAccessToken(user);
        String refresh = jwtIssuer.issueRefreshToken(user);

        tokenStore.save(user.getUsername(), refresh);

        return new TokenResponse(access, refresh, "Bearer", 900L);
    }
}

Cơ chế xoay vòng Refresh Token (Rotation) hoạt động ra sao?

Mỗi lần client yêu cầu cấp lại Access Token, máy chủ hủy ngay Refresh Token cũ và cấp một Refresh Token mới (Refresh Token Rotation):

Java
@PostMapping("/refresh")
public TokenResponse refresh(@Valid @RequestBody RefreshRequest req) {
    Jwt jwt = refreshTokenDecoder.decode(req.refreshToken());

    if (!"refresh".equals(jwt.getClaim("type"))) {
        throw new InvalidBearerTokenException("Invalid token type");
    }

    String jti = jwt.getId();
    if (!tokenStore.isValid(jti)) {
        // Reuse detection: If revoked token is presented, compromise detected
        tokenStore.revokeAllForUser(jwt.getSubject());
        throw new InvalidBearerTokenException("Token compromised");
    }

    tokenStore.revoke(jti); // Invalidate used token

    UserDetails user = userDetailsService.loadUserByUsername(jwt.getSubject());
    String newAccess = jwtIssuer.issueAccessToken(user);
    String newRefresh = jwtIssuer.issueRefreshToken(user);

    tokenStore.save(user.getUsername(), newRefresh);

    return new TokenResponse(newAccess, newRefresh, "Bearer", 900L);
}

Trục thời gian một cặp token: 10:00 login nhận A1 sống 15 phút và R1 sống 30 ngày, 10:05 gọi API bằng A1 trả 200 rồi 10:16 A1 hết hạn trả 401, 10:16 refresh kèm R1 nhận A2 và R2 đồng thời R1 bị đánh dấu revoked, 10:20 R1 lại được gửi lên nên server biết có hai bên đang cùng cầm nó và revoke cả chain

Cơ chế này tạo ra tín hiệu cảnh báo phát hiện xâm nhập: nếu một Refresh Token đã bị thu hồi đột ngột được gửi lên máy chủ lần thứ hai, hệ thống biết ngay rằng token đã bị rò rỉ cho bên thứ ba và tự động hủy bỏ toàn bộ phiên của người dùng đó.

Ba chiến lược thu hồi (Revocation) token trong kiến trúc Stateless là gì?

1. Thời hạn sống ngắn (Short Expiry)

Giải pháp đơn giản nhất không đòi hỏi thêm cơ sở hạ tầng. Access Token chỉ sống từ 5 đến 15 phút. Khi người dùng đăng xuất, hệ thống chỉ hủy Refresh Token. Cửa sổ rủi ro tối đa chỉ kéo dài bằng thời gian còn lại của Access Token.

2. Danh sách đen trong Redis (Redis Denylist)

Khi cần thu hồi Access Token ngay lập tức (admin khóa tài khoản, đổi mật khẩu), ghi mã jti của token vào Redis với thời gian TTL bằng đúng thời hạn còn lại của token. Bộ lọc OncePerRequestFilter kiểm tra Redis trên mỗi request.

3. Lưu trữ Refresh Token trong Cơ sở dữ liệu (Khuyến nghị Production)

Lưu trữ thông tin Refresh Token trong bảng cơ sở dữ liệu (refresh_tokens). Cung cấp khả năng thu hồi theo từng thiết bị hoặc xóa toàn bộ phiên đăng nhập của người dùng (revokeAllForUser).

Tại sao việc thu hồi token trong hệ thống Stateless lại phức tạp?

Bản chất của JWT là token tự chứa (self-contained) và token người cầm (bearer token):

Trục thời gian sau logout: token phát ra lúc T với jti abc và exp T cộng một giờ, T cộng 30 phút user bấm Logout và client xoá token khỏi localStorage, T cộng 45 phút attacker gửi lại chính token đó, server chỉ hỏi hai câu là chữ ký có khớp không và exp qua chưa nên trả 200 OK vì đã logout chưa không nằm trong hai câu hỏi đó

Máy chủ chỉ kiểm tra chữ ký có khớp không và thời hạn đã qua chưa, hoàn toàn không tra cứu trạng thái session trong database. Việc kết hợp Access Token ngắn và lưu Refresh Token trong DB là sự cân bằng tối ưu giữa hiệu năng mở rộng và khả năng kiểm soát an ninh.

Những cạm bẫy bảo mật kinh điển khi quản lý vòng đời JWT là gì?

  • Hardcode JWT Secret: Tuyệt đối không lưu khóa bí mật trong mã nguồn git; nạp qua biến môi trường hoặc Vault.
  • Không thiết lập claim exp: Biến token thành vĩnh cửu, không bao giờ hết hạn.
  • Để lộ dữ liệu nhạy cảm trong Payload: Bất kỳ ai cầm token cũng giải mã được các trường claims.
  • Bỏ qua kiểm tra loại token tại /refresh: Cho phép kẻ xấu dùng chính Access Token để xin cấp phát token mới.

Những điểm then chốt cần ghi nhớ là gì?

  • Sử dụng mô hình Access Token ngắn (15 phút) kết hợp Refresh Token dài hạn (30 ngày).
  • Luôn gắn claim jti duy nhất cho mỗi token để hỗ trợ truy vết và đưa vào danh sách đen.
  • Áp dụng kỹ thuật Refresh Token Rotation: đổi token và phát hiện xâm nhập khi token cũ bị tái sử dụng.
  • Lưu trữ Refresh Token trong cơ sở dữ liệu để hỗ trợ tính năng đăng xuất toàn bộ thiết bị.

Bạn đã nắm vững vòng đời phát hành và xoay vòng JWT chưa?

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao hệ thống lại áp dụng thiết kế Access Token ngắn hạn (15 phút) kết hợp Refresh Token dài hạn (30 ngày)?
  2. Q2
    Cơ chế Refresh Token Rotation mang lại lợi ích an ninh nào quan trọng nhất?
  3. Q3
    Claim nào trong JWT đại diện cho mã định danh duy nhất của token, phục vụ cho việc đưa vào danh sách đen (denylist)?
  4. Q4
    Trong endpoint /api/auth/refresh, tại sao cần kiểm tra claim 'type' == 'refresh'?
  5. Q5
    Khi cần hỗ trợ tính năng 'Đăng xuất khỏi tất cả các thiết bị' (Logout all sessions), chiến lược nào sau đây là hiệu quả nhất?

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?

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.

Lộ trình Junior Java Backend Developer

Chặng 18/21 · Authentication & Authorization — bài 11/19 của chặng

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Tổng Kết Module: Toàn Diện Các Cơ Chế Authentication