Thu Hồi JWT: Giữa hàng triệu người dùng với hàng triệu thiết bị?

Chào các bạn,
Hôm nay mình muốn kể với các bạn một câu chuyện mà mình đã “vật lộn” gần đây khi làm việc với JWT (JSON Web Token). Nếu bạn là một lập trình viên từng đau đầu với việc quản lý phiên đăng nhập trên nhiều thiết bị, chắc chắn bạn sẽ thấy vấn đề này thú vị. Câu hỏi lớn là: Làm sao để thu hồi token trên một thiết bị cụ thể (ví dụ: iPad) mà không ảnh hưởng đến các thiết bị khác (iPhone, Desktop), lại còn không được lưu token trên server, và quan trọng hơn, phải tối ưu hiệu năng khi có hàng triệu người dùng? Nghe đã thấy đau đầu rồi, đúng không? Nhưng đừng lo, mình đã tìm ra một cách hay ho và muốn chia sẻ với các bạn qua bài blog này.

JWT và cơn ác mộng thu hồi

JWT là một “ngôi sao” trong thế giới xác thực vì tính stateless (không trạng thái). Bạn cấp token, client giữ, server chỉ cần kiểm tra chữ ký – đơn giản, nhanh gọn. Nhưng cái gì cũng có giá của nó. Một ngày đẹp trời, sếp bảo mình: “User A đăng nhập trên iPad, iPhone, và Desktop. Giờ họ muốn đăng xuất mỗi iPad thôi, còn lại giữ nguyên. À, không được lưu token trên server nhé!” Mình nghe xong mà muốn khóc – stateless thì làm sao thu hồi được đây?

Cách truyền thống là lưu token vào blacklist trên server, nhưng yêu cầu “không lưu token” đã dập tắt ý tưởng đó. Mình bắt đầu mò mẫm và nhận ra: nếu không thể lưu token, mình phải tìm cách “đánh dấu” thiết bị và để client tự xử lý. Sau vài ngày thử nghiệm (và vài cốc cà phê), mình đã tìm ra một giải pháp: Bloom Filter kết hợp Push Notification. Nghe lạ đúng không? Để mình kể chi tiết nhé.

Ý tưởng: Đánh dấu thiết bị và để client tự kiểm tra

Mình nghĩ thế này: thay vì server phải trả lời từng client rằng “token của mày bị thu hồi chưa?”, mình nhúng thông tin thiết bị vào token và để client tự kiểm tra trạng thái. Nhưng vấn đề là, nếu client cứ liên tục hỏi server, với hàng triệu người dùng thì server sẽ “toang” ngay. Vậy làm sao để tối ưu? Đây là lúc Bloom FilterPush Notification lên sàn.

Bước 1: Nhúng device_id vào JWT

Mỗi khi user đăng nhập, mình cấp token với một trường device_id riêng cho từng thiết bị. Ví dụ:

{
  "sub": "userA",
  "device_id": "ipad-12345",
  "exp": 1710892800
}

iPhone sẽ có device_id: “iphone-54321“, Desktop là desktop-98765. Dễ thôi, đúng không?

Bước 2: Bloom Filter – “Siêu nhân” kiểm tra cục bộ

Bloom Filter là gì? Nói đơn giản, nó như một “danh sách đen siêu nhỏ gọn” mà client có thể tự kiểm tra xem device_id của mình có bị thu hồi không. Nó không lưu chính xác từng token, mà dùng bit để đánh dấu – cực kỳ nhẹ và nhanh. Mình tạo một Bloom Filter trên server, thêm device_id bị thu hồi vào đó, rồi gửi cho client.

Ví dụ, khi user muốn thu hồi iPad:

  • Server thêm “ipad-12345” vào Bloom Filter.
  • Bloom Filter được mã hóa thành base64 (chỉ vài KB) và gửi đi.

Client nhận được Bloom Filter, kiểm tra cục bộ:

  • iPad thấy “ipad-12345” có trong filter → tự đăng xuất.
  • iPhone và Desktop không thấy device_id của mình → yên tâm hoạt động.

Bước 3: Push Notification – Giảm tải server

Thay vì client cứ 5 phút gọi server hỏi “Tao bị thu hồi chưa?“, mình dùng Push Notification (qua Firebase) để gửi Bloom Filter mới mỗi khi có thu hồi. Với hàng triệu người dùng, việc này giảm tải server hàng nghìn lần. Chỉ khi user chủ động thu hồi (rất hiếm), server mới gửi thông báo – quá ngọt!

Ví dụ code: NestJS + Firebase

Mình đã thử implement bằng NestJS và Firebase. Đây là một đoạn code đơn giản để các bạn hình dung:

Server (NestJS)

// auth.service.ts
async revokeDevice(userId: string, deviceId: string): Promise<void> {
  this.bloomFilter.add(deviceId); // Thêm device_id vào Bloom Filter
  await this.sendBloomFilterUpdate(userId); // Gửi qua Push Notification
}

private async sendBloomFilterUpdate(userId: string): Promise<void> {
  const bloomData = Buffer.from(this.bloomFilter.buckets).toString('base64');
  const message = {
    data: { userId, bloomFilter: bloomData },
    topic: `user-${userId}`,
  };
  await firebaseAdmin.messaging().send(message);
}

Client (giả lập)

// client.js
checkRevocation() {
  if (this.bloomFilter.test(this.deviceId)) {
    console.log(`Device ${this.deviceId} bị thu hồi. Đăng xuất đây!`);
    this.logout();
  } else {
    console.log(`Device ${this.deviceId} vẫn ngon lành.`);
  }
}

Chạy thử trên local, mình thấy iPad tự logout khi nhận Bloom Filter mới, còn iPhone với Desktop thì vô tư. Quá đã!

Hiệu năng: Hàng triệu người dùng thì sao?

Nếu mỗi client gọi server 1 lần/phút, với 1 triệu người dùng, server phải xử lý 60 triệu request/giờ – kinh khủng! Nhưng với Bloom Filter + Push:

  • Bloom Filter chỉ vài KB, kiểm tra cục bộ nhanh như chớp.
  • Push Notification chỉ gửi khi có thu hồi (có thể 1-2 lần/ngày/user).
  • Firebase xử lý hàng triệu thông báo dễ như ăn kẹo.

So sánh: từ 60 triệu request/giờ xuống vài triệu request/ngày. Server của mình thở phào nhẹ nhõm!

Cảm nhận cá nhân

Ban đầu mình nghĩ chuyện thu hồi JWT mà không lưu token là bất khả thi, nhưng Bloom Filter đã thay đổi cách nhìn của mình. Nó không chỉ giải quyết vấn đề mà còn dạy mình cách tư duy tối ưu hiệu năng. Nếu bạn đang làm dự án lớn, hãy thử giải pháp này – vừa nhẹ, vừa nhanh, lại còn “xịn”.

Các bạn thấy sao? Có ai từng gặp bài toán tương tự và giải quyết khác không? Comment chia sẻ với mình nhé, mình rất muốn học hỏi thêm từ cộng các bạn!

Happy coding!