Skip to content
Home
Go back

Thu hồi JWT: Cái giá của việc chọn stateless

bài về RBAC, mình có kể một tình huống: đổi role của một user từ editor xuống viewer, nhưng bạn đó vẫn sửa được bài viết bình thường suốt gần một tuần. Lý do là permission được nhét thẳng vào JWT lúc login, TTL token 7 ngày, đổi dữ liệu trong DB không có tác dụng gì cho tới khi token cũ tự hết hạn.

Lúc đó mình chỉ nói ngắn gọn “phải có cơ chế revoke token chủ động”. Bài này là phần chi tiết của câu đó, và hóa ra “thu hồi” một JWT khó hơn nhiều so với tưởng tượng ban đầu.

Mục lục

Xem mục lục

Vì sao JWT khó revoke, bản chất là gì

JWT được thiết kế stateless: server không lưu trạng thái của bất kỳ token nào cả, chỉ cần verify chữ ký (signature) là biết token có hợp lệ hay không, không cần tra cứu DB hay bất cứ đâu. Đây chính là lý do JWT nhanh và scale tốt, không có single point of truth nào phải hỏi mỗi request.

Nhưng cũng chính vì vậy mà “revoke” trở thành nghịch lý. Muốn thu hồi một token nghĩa là server phải nhớ rằng token đó không còn hợp lệ nữa, tức là phải lưu trạng thái ở đâu đó. Mà lưu trạng thái thì phá vỡ đúng cái lợi ích stateless ban đầu.

“Logout” theo kiểu xóa token ở phía client (localStorage, cookie…) không giải quyết được gì cả, vì server chưa từng biết token đó tồn tại để mà thu hồi. Ai vẫn giữ được token cũ (đã copy ra trước, hoặc chặn được qua XSS) thì vẫn dùng bình thường cho tới khi hết hạn tự nhiên.

Các cách tiếp cận, và cái giá của mỗi cách

Trước khi liệt kê, cần tách rõ hai nhóm khác bản chất, kẻo lẫn lộn như chính mình từng lẫn lộn lúc mới tìm hiểu:

Nhóm 1: Revoke tức thì

Token Blacklist / Denylist

Lưu danh sách token đã bị revoke, thường trong Redis, key là jti (JWT ID, một claim chuẩn trong JWT dùng để định danh duy nhất cho từng token), TTL đặt bằng đúng thời gian còn lại của token đó, để tự dọn dẹp khi token hết hạn tự nhiên.

// jwt-blacklist.service.ts
@Injectable()
export class JwtBlacklistService {
  constructor(private readonly redis: Redis) {}

  async revoke(jti: string, ttlSeconds: number) {
    await this.redis.set(`blacklist:${jti}`, "1", "EX", ttlSeconds);
  }

  async isRevoked(jti: string): Promise<boolean> {
    return (await this.redis.exists(`blacklist:${jti}`)) === 1;
  }
}

Mỗi request đều phải tra Redis xem token có bị blacklist chưa, đổi lại revoke có hiệu lực ngay lập tức, cho đúng 1 token cụ thể.

Token Versioning

Mỗi user có một cột tokenVersion trong DB. Lúc login, version hiện tại được nhúng vào JWT. Mỗi request, so sánh version trong token với version mới nhất trong DB, lệch là từ chối.

// jwt.strategy.ts
async validate(payload: JwtPayload) {
  const user = await this.usersService.findById(payload.sub);

  if (user.tokenVersion !== payload.tokenVersion) {
    throw new UnauthorizedException("Token không còn hợp lệ");
  }

  return user;
}
async logoutAllDevices(userId: number) {
  await this.usersRepo.increment({ id: userId }, "tokenVersion", 1);
}

Đơn giản hơn blacklist vì không cần Redis riêng, tận dụng luôn DB user sẵn có. Nhưng nhược điểm là tăng version sẽ invalidate toàn bộ token của user cùng lúc, không revoke được đúng 1 token cụ thể như blacklist theo jti.

Nhóm 2: Không revoke được, chỉ giảm vùng ảnh hưởng

Short-lived Access Token kèm Refresh Token

Đây là chỗ dễ hiểu lầm nhất, kể cả với chính mình lúc mới tìm hiểu: refresh token dễ revoke không có nghĩa là access token revoke được.

Refresh token vốn dĩ đã là stateful, được lưu server-side (DB hoặc Redis), nên revoke nó chỉ là update một bản ghi bình thường, không có gì đặc biệt:

// refresh-tokens.service.ts
async refresh(rawToken: string) {
  const stored = await this.refreshTokensRepo.findOne({
    where: { tokenHash: hash(rawToken), revokedAt: IsNull() },
  });

  if (!stored || stored.expiresAt < new Date()) {
    throw new UnauthorizedException();
  }

  return this.issueAccessToken(stored.userId);
}

async revoke(tokenHash: string) {
  await this.refreshTokensRepo.update({ tokenHash }, { revokedAt: new Date() });
}

Nhưng revoke refresh token chỉ chặn được lần refresh tiếp theo, tức là chặn việc cấp access token mới. Access token đã phát hành trước đó, nếu còn hạn, vẫn hợp lệ y hệt như JWT thuần túy, vì access token vẫn verify bằng chữ ký, server không tra cứu gì thêm khi validate nó. Nói cách khác, đây không phải là một cách “revoke access token” thứ ba, mà là một chiến lược khác hẳn: chấp nhận access token không revoke được, chỉ chủ động phát hành access token với TTL đủ ngắn ngay từ đầu (vài phút tới vài chục phút) để vùng rủi ro không còn đáng lo, thay vì trả chi phí tra cứu blacklist/version trên mọi request.

Logout 1 thiết bị và logout tất cả thiết bị

Đây là chỗ khác biệt rõ nhất giữa các cách trên:

Nếu sản phẩm có yêu cầu kiểu “xem danh sách thiết bị đang đăng nhập, đăng xuất từng cái”, Refresh Token lưu server-side vẫn là nền tảng cần có, chỉ là đừng ngộ nhận nó tự thân giải quyết được bài toán revoke tức thì.

Đánh đổi hiệu năng

Hai nhóm đánh đổi khác hẳn nhau, không phải cùng một trục chi phí:

Nên nếu hệ thống có traffic lớn và chấp nhận được cửa sổ rủi ro vài phút, ưu tiên Nhóm 2 cho phần lớn route. Chỉ cần thêm Nhóm 1 cho một số tình huống thực sự cần giết ngay lập tức, như phát hiện tài khoản bị chiếm.

Quay lại tình huống của bài trước

Vụ đổi role editor xuống viewer mà token vẫn còn hiệu lực 7 ngày, nguyên nhân gốc là hệ thống lúc đó dùng access token dài hạn, nhúng thẳng permission vào token, không có bất kỳ cơ chế revoke nào cả.

Cách mình sửa cuối cùng là chuyển sang Token Versioning, vì đơn giản, không cần thêm Redis mới, tận dụng bảng users sẵn có. Mỗi lần đổi role cho user, tăng tokenVersion của user đó lên 1, JWT cũ lập tức bị từ chối ở request tiếp theo, bất kể còn hạn bao lâu.

Điều mình học được là: chọn permission nhúng thẳng vào JWT (như đã nói ở bài RBAC) và không có cơ chế revoke chủ động là một tổ hợp rất dễ gây sự cố, vì độ trễ cập nhật quyền không phải vài giây, mà là cả một khoảng thời gian bằng đúng TTL của token. Với hệ thống nhạy cảm, khoảng trễ đó là không chấp nhận được.

Tổng kết

Chọn JWT vì muốn stateless, rồi lại thêm blacklist hay version check để revoke được, nghe có vẻ như tự đá vào lợi ích ban đầu của chính mình. Nhưng thực tế phần lớn hệ thống production đều phải đánh đổi một phần “thuần túy” đó để lấy lại khả năng kiểm soát, vấn đề chỉ là đánh đổi bao nhiêu cho vừa đủ.


Share this post:

Previous Post
ABAC: Khi RBAC không còn đủ, và bài học đầu tiên với policy engine