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

Nội dung

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!

Anthony Nguyễn

Cây bút chính tại VietnamTutor

Bài viết cùng chuyên mục

Quy trình vibe coding 7 bước: từ ý tưởng đến prototype

Quy trình vibe coding hiệu quả bắt đầu từ giả thuyết kinh doanh, không phải prompt dài. Bài viết hướng dẫn 7 bước từ brief, dữ

Vibe coding cho doanh nghiệp: việc nào nên làm bằng AI?

Vibe coding cho doanh nghiệp hữu ích khi cần thử prototype, dashboard hoặc automation nhỏ. Bài viết giúp bạn phân loại use case theo mức rủi

AI viết code nhanh hơn review: kiểm soát thế nào?

Khi AI viết code nhanh hơn khả năng review của team, doanh nghiệp cần đổi cách kiểm soát chất lượng. Bài viết đưa ra framework thực

Git detached HEAD là gì? Cách thoát an toàn

Bài viết này giải thích detached HEAD trong Git, cách nhận biết trạng thái này và các bước thoát ra an toàn mà không mất code.

Rủi ro vibe coding: 9 lỗi khiến prototype khó vận hành

Bài viết này chỉ ra 9 rủi ro phổ biến khi vibe coding và cách kiểm soát để prototype không biến thành gánh nặng vận hành.

Vibe coding là gì? Cách thử ý tưởng phần mềm bằng AI

Vibe coding giúp chủ doanh nghiệp biến ý tưởng phần mềm thành prototype nhanh hơn bằng AI. Bài viết này chỉ ra khi nào nên thử,

GitHub Actions CI/CD: quy trình deploy website an toàn

Hướng dẫn xây pipeline GitHub Actions CI/CD cho website: build, test, cache dependency, đóng artifact, deploy staging/production và quản lý secret an toàn.

.gitignore không hoạt động: nguyên nhân và cách sửa

Bài viết giúp bạn kiểm tra vì sao .gitignore không hoạt động, sửa lỗi file đã tracked và dùng git check-ignore để debug pattern.

Git push bị rejected: cách sửa non-fast-forward

Bài viết giải thích vì sao git push bị rejected, cách đọc lỗi non-fast-forward và quy trình xử lý an toàn trước khi push lại.

Git reset revert restore: chọn lệnh đúng

Bài viết so sánh git reset, git revert và git restore theo mục đích sử dụng: sửa staging area, khôi phục file, undo commit chưa push

Git commit vào nhánh sai: cách chuyển an toàn

Bài viết hướng dẫn xử lý git commit vào nhánh sai theo từng tình huống: commit chưa push, đã push, nhiều commit liên tiếp hoặc branch

TypeScript cho website doanh nghiệp: API, form và lỗi

TypeScript cho website doanh nghiệp đáng dùng khi bạn cần kiểm soát API contract, form schema, CMS payload và cấu hình môi trường. Bài này giúp