Backend

99 Ngày Spring — Ngày 44: JWT — phát hành token

SSite Admin
9 tháng 09, 2026 7 phút đọc 9 lượt xem
99 Ngày Spring — Ngày 44: JWT — phát hành token

Ba ngày qua ta xác thực bằng session: server nhớ ai đang đăng nhập, trình duyệt giữ cookie. Mô hình đó gãy khi client là ứng dụng di động, hoặc khi chạy nhiều server không chia sẻ session. Hôm nay là lời giải phổ biến nhất — JWT, đảo ngược mô hình: server không nhớ gì cả, mọi thứ nằm trong token. Nhưng trước khi mê nó, phải hiểu hai điều: payload không hề được mã hóa, và token đã phát không thu hồi được.

Sketchnote Ngày 44: JWT phát hành token — ba phần header payload signature chỉ là Base64 không mã hóa, các claim sub iat exp, JwtService ký bằng khóa bí mật, endpoint đăng nhập, và đánh đổi không thu hồi được cùng refresh token

JWT là gì — và điều nhiều người hiểu sai

Một JWT chỉ là ba đoạn Base64 nối bằng dấu chấm. Điều quan trọng nhất, cũng là điều bị hiểu sai nhiều nhất: đó không phải mã hóa. Bất kỳ ai cầm được token đều đọc được toàn bộ nội dung bên trong — chữ ký chỉ đảm bảo nó chưa bị sửa.

// Ba ngày qua ta dùng SESSION: server nhớ ai đang đăng nhập, trình duyệt giữ cookie.
// Mô hình đó gãy khi client là app di động, hoặc khi chạy nhiều server không chia sẻ session.
// JWT đảo ngược: SERVER KHÔNG NHỚ GÌ CẢ, mọi thông tin nằm trong chính token.

// Một JWT là ba phần, nối bằng dấu chấm — và chỉ là Base64URL, KHÔNG PHẢI mã hóa:
//   eyJhbGciOiJIUzI1NiJ9 . eyJzdWIiOiJhbkB4LmNvbSJ9 . 4pcPyMD09olPSyXnrXCjTw
//   └─── header ────────┘  └──── payload ─────────┘  └──── signature ─────┘

// header  : thuật toán ký, vd { "alg": "HS256" }
// payload : các claim — dữ liệu bạn muốn mang theo
// signature: chữ ký của (header + payload) bằng KHÓA BÍ MẬT

// ❗ ĐIỀU QUAN TRỌNG NHẤT, nhiều người hiểu sai:
// Payload KHÔNG được mã hóa. Ai cầm token cũng đọc được nội dung — dán vào
// jwt.io là thấy hết. Chữ ký chỉ đảm bảo token KHÔNG BỊ SỬA, không giấu gì cả.
// → TUYỆT ĐỐI không đặt mật khẩu, số thẻ, hay dữ liệu cá nhân nhạy cảm vào payload.

// Các claim chuẩn nên có:
//   sub — ai (subject: email hoặc user id)
//   iat — phát hành lúc nào
//   exp — hết hạn lúc nào   ❗ KHÔNG CÓ exp = token sống mãi mãi
  • Payload đọc được bằng mắt thường sau khi giải Base64 — nên tuyệt đối không đặt mật khẩu hay dữ liệu nhạy cảm vào đó.

  • Chữ ký trả lời câu hỏi token này có bị sửa không, chứ không giấu nội dung đi.

  • exp là claim bắt buộc phải có: token không hạn là token sống vĩnh viễn, và đó là một sự cố chờ xảy ra.

Phát hành token

Phần code khá ngắn, và điều đáng chú ý không nằm ở cú pháp mà ở khóa bí mật: nó quyết định toàn bộ độ an toàn của hệ thống. Lộ khóa nghĩa là bất kỳ ai cũng tự ký được một token nói rằng mình là admin.

// Thư viện: jjwt (io.jsonwebtoken) là lựa chọn phổ biến nhất cho Spring Boot.
@Service
public class JwtService {

    private final SecretKey key;                    // ❗ đọc từ cấu hình, KHÔNG hard-code
    private final long hanSong = Duration.ofMinutes(15).toMillis();

    public JwtService(@Value("${app.jwt.secret}") String secret) {
        this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
    }

    public String phatHanh(UserDetails nd) {
        var now = new Date();
        return Jwts.builder()
                .subject(nd.getUsername())
                .claim("quyen", nd.getAuthorities().stream()
                        .map(GrantedAuthority::getAuthority).toList())
                .issuedAt(now)
                .expiration(new Date(now.getTime() + hanSong))
                .signWith(key)
                .compact();
    }
}

// ❗ KHÓA BÍ MẬT — nơi sai lầm gây hậu quả nặng nhất:
//   - HS256 cần khóa TỐI THIỂU 256 bit (32 ký tự); ngắn hơn thì jjwt từ chối ký
//   - ĐỪNG commit khóa vào git. Đọc từ biến môi trường.
//   - Lộ khóa = ai cũng tự phát được token cho bất kỳ tài khoản nào, kể cả admin.

// Endpoint đăng nhập — dùng lại AuthenticationManager của Ngày 43:
@PostMapping("/api/dang-nhap")
public TokenResponse dangNhap(@RequestBody LoginRequest req) {
    var auth = authManager.authenticate(          // ném BadCredentials nếu sai
            new UsernamePasswordAuthenticationToken(req.email(), req.matKhau()));
    return new TokenResponse(jwtService.phatHanh((UserDetails) auth.getPrincipal()));
}
  • HS256 đòi khóa tối thiểu 256 bit; thư viện sẽ từ chối ký nếu khóa ngắn hơn — đó là một sự bảo vệ, không phải phiền toái.

  • Đừng bao giờ commit khóa vào git, kể cả trong repo riêng tư. Đọc từ biến môi trường.

  • Endpoint đăng nhập dùng lại toàn bộ AuthenticationManagerUserDetailsService của Ngày 43 — không phải viết lại logic xác thực.

Đánh đổi: không thu hồi được

Đây là phần quan trọng nhất của bài, và cũng là phần hay bị bỏ qua trong các hướng dẫn. JWT nhanh và không cần trạng thái, nhưng chính vì server không nhớ gì nên nó cũng không có gì để xóa khi bạn cần chặn một token.

// ĐÁNH ĐỔI LỚN NHẤT CỦA JWT: KHÔNG THU HỒI ĐƯỢC.
// Session thì server xóa một dòng là xong. Token đã ký thì server không "nhớ" nó,
// nên cũng không có gì để xóa — nó hợp lệ cho tới đúng giây hết hạn.
//   → Sa thải nhân viên? Token của họ vẫn dùng được tới khi exp tới.
//   → Đổi mật khẩu vì bị hack? Token cũ vẫn sống (Ngày 43 đã cảnh báo).

// BA cách xử lý, theo thứ tự thực dụng:
// 1. HẠN NGẮN + REFRESH TOKEN — cách phổ biến nhất:
//    access token 15 phút (mang trong header, dùng liên tục)
//    refresh token 7 ngày (LƯU TRONG DB, có thể xóa được → thu hồi được)
// 2. Danh sách đen token trong Redis (Ngày 37) — nhưng làm vậy là bạn đã
//    quay lại có trạng thái, tức mất đi lý do chính để chọn JWT.
// 3. Đổi khóa bí mật — vô hiệu hóa TOÀN BỘ token, kể cả người vô can.

// LƯU TOKEN Ở ĐÂU (phía client)? Không có lựa chọn nào hoàn hảo:
//   localStorage    → tiện, nhưng JavaScript đọc được → dính XSS là mất token
//   cookie HttpOnly → JS không đọc được, nhưng phải lo CSRF (Ngày 42)
//   bộ nhớ RAM      → an toàn nhất, mất khi tải lại trang

// KHI NÀO ĐỪNG DÙNG JWT: nếu client của bạn chỉ là một web truyền thống
// cùng domain, thì SESSION đơn giản hơn, thu hồi được, và ít bẫy hơn nhiều.
// JWT sinh ra cho ứng dụng di động, cho nhiều service, cho hệ thống phân tán —
// đừng chọn nó chỉ vì nghe hiện đại hơn.
  • Hạn ngắn cộng refresh token là cách thực dụng nhất: access token sống 15 phút, còn refresh token nằm trong database nên xóa được.

  • Dùng danh sách đen trong Redis thì bạn đã quay lại có trạng thái — mất đi chính lý do chọn JWT.

  • Lưu token ở đâu cũng có điểm yếu: localStorage sợ XSS, cookie HttpOnly phải lo CSRF.

  • Web truyền thống cùng domain thì session vẫn tốt hơn — đơn giản hơn, thu hồi được, ít bẫy hơn. Đừng chọn JWT chỉ vì nghe hiện đại.

Bài tập nhỏ

  • Phát một token rồi dán vào jwt.io — đọc thẳng payload để tự thuyết phục mình rằng nó không được mã hóa.

  • Đặt khóa bí mật ngắn hơn 32 ký tự và xem thư viện từ chối ký với thông báo gì.

  • Sửa một ký tự trong phần payload của token rồi gửi lên — quan sát chữ ký không còn khớp.

  • Phát token với hạn 30 giây, gọi API ngay thì được, đợi một phút rồi gọi lại để thấy nó bị từ chối.

  • Bỏ hẳn claim exp và tự trả lời: token này sống tới bao giờ, và làm sao để chặn nó?

Kết lại

Bốn ý gói lại hôm nay: JWT là ba đoạn Base64 và payload không hề được mã hóa — chữ ký chỉ chống sửa đổi, nên đừng bỏ dữ liệu nhạy cảm vào; exp là claim bắt buộc, còn khóa bí mật phải đủ dài và không bao giờ nằm trong git; endpoint đăng nhập dùng lại nguyên AuthenticationManager của Ngày 43; và đánh đổi lớn nhất là không thu hồi được — nên hạn ngắn cộng refresh token là lời giải thực dụng, còn web truyền thống thì session vẫn tốt hơn. Ngày 45 ta hoàn thiện nửa còn lại: filter xác thực JWTOncePerRequestFilter, đặt SecurityContext, và cấu hình stateless. Hẹn gặp lại!

S

Site Admin

Engineer and writer. Building things with TypeScript and distributed systems.

Bình luận (0)

Bạn cần đăng nhập bằng Google để bình luận.

Hãy là người bình luận đầu tiên.

Bài viết liên quan

99 Ngày Java — Ngày 45: File I/O với java.io

Hai họ stream cho byte và reader cho ký tự, buffered biến một triệu syscall thành hơn trăm, bẫy charset mặc định làm vỡ tiếng Việt — và đọc file lớn bằng Files.lines thay vì nạp hết vào RAM.

10 thg 9, 20267 phút1
99 Ngày Spring — Ngày 45: JWT — filter xác thực

OncePerRequestFilter đọc header Bearer và đặt SecurityContext, lắp trước UsernamePasswordAuthenticationFilter cùng cấu hình STATELESS, khi nào tắt CSRF là hợp lệ — và refresh token lưu DB để lấy lại khả năng thu hồi.

10 thg 9, 20267 phút1
99 Ngày Java — Ngày 44: try-with-resources

Vì sao finally viết tay vừa dài vừa sai, cú pháp nhiều tài nguyên đóng theo thứ tự ngược, suppressed exception giữ lại lỗi gốc — và bẫy quên đóng stream của Files.walk.

9 thg 9, 20267 phút7