Refresh token: luồng cấp mới, rotation và thu hồi
Bài trước bạn đã biết JWT là token tự chứa: server ký xong thì không sửa được nội dung, nhưng cũng không thu hồi được trước khi hết hạn. Đó chính là lý do tồn tại của refresh token: access token sống thật ngắn, còn refresh token sống dài nhưng bị server canh từng lần dùng.
Sau 20 phút này, bạn sẽ:
- Giải thích được vì sao phải tách access token ngắn hạn và refresh token dài hạn.
- Vẽ lại được luồng cấp mới: ai gọi ai, cái gì được lưu ở database.
- Cài được rotation và reuse detection ở mức pseudocode.
- Liệt kê được các sự kiện bắt buộc phải thu hồi token.
Access token ngắn hạn và cái giá của việc không thu hồi được
Câu hỏi tôi luôn hỏi khi review một luồng auth: token này ai giữ, sống bao lâu, bị lộ thì sao?
| Token | Ai giữ | Sống bao lâu | Bị lộ thì sao |
|---|---|---|---|
| Access token (JWT) | Client, gửi trong header mỗi request | 5 đến 15 phút | Dùng được đến khi hết hạn, nên phải ngắn |
| Refresh token | Client, chỉ gửi tới endpoint /auth/refresh |
7 đến 30 ngày | Thu hồi được ngay nếu server có lưu và kiểm tra trạng thái |
Nếu bạn cho access token sống 30 ngày cho tiện, bạn vừa bỏ luôn khả năng thu hồi. Nếu bạn cho refresh token đi kèm mọi request, bạn vừa tăng bề mặt lộ và vừa làm chậm mọi API. Nguyên tắc: access token ngắn hạn, refresh token chỉ đi tới đúng một endpoint.
Luồng cấp mới: ai gọi ai, lưu gì ở đâu
sequenceDiagram
participant C as Client
participant A as API
participant D as Database
C->>A: POST /auth/refresh kèm refresh token
A->>D: Tra theo hash của token
D-->>A: Bản ghi hợp lệ, chưa dùng, family f1
A->>D: Đánh dấu token cũ đã dùng, tạo token mới cùng family
A-->>C: access token mới và refresh token mới
C->>A: GET /orders kèm Bearer access token
A-->>C: 200 OK
Bốn điểm cần nhớ:
- Database chỉ lưu hash của refresh token, không lưu plaintext. Lộ database thì token cũng vô dụng.
- Endpoint refresh phải kiểm tra cả tồn tại, hạn dùng, trạng thái đã dùng hay chưa.
- Response trả về cặp token mới, không chỉ access token.
- Refresh token không bao giờ đi kèm request nghiệp vụ thông thường.
Note: Refresh token không phải "access token phiên bản dài hạn". Nó là một bản ghi có trạng thái trong database, còn access token vẫn là JWT không trạng thái. Hai thứ chạy theo hai cơ chế khác nhau.
Bài tập 1. User mở app 8 tiếng mỗi ngày, access token sống 10 phút, refresh token sống 30 ngày. Ước lượng số lần gọi /auth/refresh trong một ngày và nêu một cách giảm con số đó mà không tăng rủi ro.
Xem gợi ý
480 phút chia 10 phút, khoảng 48 lần mỗi ngày, chưa tính các lần app mở lại. Cách giảm hợp lý: cho access token sống 15 phút (khoảng 32 lần), hoặc refresh sớm trước khi hết hạn vài chục giây để tránh nhiều request song song cùng refresh. Không nên "giảm" bằng cách kéo dài access token lên vài ngày.
Rotation: mỗi lần refresh là một lần đổi chìa
Rotation nghĩa là refresh token dùng một lần. Mỗi lần cấp mới, token cũ bị đánh dấu đã dùng, token mới ra đời và cùng thuộc một familyId (còn gọi là token family). Cả chuỗi token của một lần đăng nhập nằm chung một family.
Vì sao cần family? Vì khi có sự cố, bạn thu hồi một lần cả chuỗi, không phải đi lùng từng token rời rạc.
Ví dụ thực tế tôi từng gặp: một app không rotation, refresh token sống 90 ngày, không có bản ghi trạng thái. Khi người dùng báo "tài khoản có người khác đăng nhập", không có cách nào thu hồi ngoài việc đổi mật khẩu và chờ. Có rotation kèm trạng thái, sự cố đó xử lý trong vài giây.
Bài tập 2. Bạn quên đánh dấu token cũ là đã dùng. Hãy mô tả hệ quả và cách kiểm tra nhanh bằng một request lặp lại.
Xem gợi ý
Hệ quả: một refresh token bị lộ vẫn dùng được mãi, và bạn không có tín hiệu nào để phát hiện. Cách kiểm tra: gọi /auth/refresh hai lần với cùng một refresh token. Nếu lần thứ hai vẫn trả 200 thì rotation chưa hoạt động. Kỳ vọng đúng là lần hai trả 401 và family bị thu hồi.
Phát hiện dùng lại và ba sự kiện phải thu hồi
Reuse detection là lá chắn chính của luồng này: nếu một refresh token đã bị đánh dấu dùng mà lại xuất hiện, gần như chắc chắn có hai bên đang giữ cùng một token. Hướng xử lý an toàn là thu hồi cả family, ghi log và buộc đăng nhập lại.
flowchart TD
Req["🔄 Yêu cầu refresh"] --> Found{"Token có trong DB?"}
Found -- Không --> Deny["❌ 401, buộc đăng nhập lại"]
Found -- Có --> Used{"Đã đánh dấu dùng?"}
Used -- Có --> Revoke["🚨 Thu hồi cả family, ghi log"]
Used -- Không --> Rotate["✅ Cấp cặp token mới"]
// POST /auth/refresh
const row = await db.findRefreshToken(hash(incoming));
if (!row) return res.status(401).end();
if (row.usedAt) { // token đã dùng mà còn quay lại
await db.revokeFamily(row.familyId); // thu hồi cả chuỗi
await audit.log("refresh_reuse", row.userId);
return res.status(401).end();
}
await db.markUsed(row.id, Date.now());
const next = await issueRefreshToken(row.userId, row.familyId);
return res.json({ accessToken: signAccess(row.userId), refreshToken: next });
Ba sự kiện bắt buộc phải thu hồi refresh token (và thường là cả family):
- Người dùng bấm logout.
- Người dùng đổi hoặc đặt lại mật khẩu.
- Thiết bị mất, hoặc quản trị viên khóa tài khoản.
Bài tập 3. Viết pseudocode cho hàm revokeFamily(familyId) và cho biết endpoint logout cần tham số gì.
Xem gợi ý
async function revokeFamily(familyId) {
await db.refreshTokens.updateMany(
{ familyId, revokedAt: null },
{ revokedAt: now(), reason: "logout_or_reuse" }
);
}
Endpoint logout nhận refresh token hiện tại (hoặc familyId lấy từ token đó), xác thực chủ sở hữu, rồi thu hồi cả family. Không nhận familyId tùy ý từ client mà không kiểm tra, vì như vậy người khác có thể thu hồi token của người khác.
Kiểm tra nhanh bằng cloze:
Access token nên sống ngắn, khoảng _____, để giảm thiệt hại khi bị lộ. Server lưu refresh token dưới dạng _____ để lộ database cũng không dùng lại được. Mỗi lần refresh, token cũ bị vô hiệu và token mới được cấp, kỹ thuật này gọi là _____. Nếu một refresh token đã đánh dấu dùng mà xuất hiện lần nữa, server phải _____ và ghi log. Ba sự kiện thường phải thu hồi refresh token là _____.
Front: Vì sao access token phải sống ngắn? Back: Vì JWT không thu hồi được trước hạn, bị lộ là dùng được đến khi hết hạn.
Front: Refresh token khác access token ở điểm cốt lõi nào? Back: Refresh token có bản ghi trạng thái ở server nên kiểm tra và thu hồi được.
Front: Refresh token nên được gửi tới đâu? Back: Chỉ tới endpoint /auth/refresh, không gửi kèm các request nghiệp vụ.
Front: Rotation là gì? Back: Refresh token dùng một lần: mỗi lần cấp mới thì token cũ bị vô hiệu.
Front: Server lưu refresh token dưới dạng nào? Back: Hash của token, không lưu plaintext.
Front: familyId dùng để làm gì? Back: Nhóm các refresh token của cùng một chuỗi để thu hồi một lần cả chuỗi.
Front: Reuse detection là gì? Back: Phát hiện refresh token đã đánh dấu dùng mà xuất hiện lại, dấu hiệu token bị lộ.
Front: Khi phát hiện reuse thì xử lý thế nào? Back: Thu hồi cả family, ghi log, trả 401 và buộc đăng nhập lại.
Front: TTL điển hình của refresh token? Back: Khoảng 7 đến 30 ngày, tùy mức nhạy cảm của sản phẩm.
Front: Ba sự kiện bắt buộc thu hồi refresh token? Back: Logout, đổi hoặc đặt lại mật khẩu, thiết bị mất hoặc tài khoản bị khóa.
Front: Quên đánh dấu token cũ đã dùng thì sao? Back: Không phát hiện được reuse và token cũ dùng lại được mãi.
Front: Refresh endpoint trả về gì? Back: Cặp access token mới và refresh token mới.
Front: Kiểm tra nhanh rotation có hoạt động không? Back: Gọi refresh hai lần với cùng một token, lần hai phải trả 401.
Ba câu hỏi trước khi ship luồng refresh
Trước khi merge, tự trả lời ba câu này. Nếu câu nào chưa rõ, đừng ship.
- Ai giữ token này? Refresh token nên nằm trong cookie
httpOnly,secure,sameSite, không nằm trong chỗ mà JavaScript đọc được. - Sống bao lâu? Access token 5 đến 15 phút. Refresh token 7 đến 30 ngày. Đừng "tạm" kéo dài rồi quên.
- Bị lộ thì sao? Phải trả lời được: thu hồi bằng lệnh nào, mất bao lâu, ai nhận log.
Lỗi hay gặp nhất của người mới: viết endpoint refresh chỉ kiểm tra chữ ký JWT rồi cấp access token mới. Như vậy refresh token trở thành access token vĩnh viễn và không thu hồi được gì cả. Lỗi thứ hai: rotation nhưng không lưu trạng thái "đã dùng", nên rotation chỉ là đổi tên. Lỗi thứ ba: nhiều request song song cùng refresh, token đầu tiên hợp lệ, các token sau bị coi là reuse và người dùng bị đăng xuất oan. Cách chữa là refresh sớm trước khi hết hạn và gom các lần refresh đồng thời về một promise duy nhất ở client.
Chốt lại và bước tiếp theo
- Access token ngắn hạn, refresh token dài hạn nhưng có trạng thái ở server.
- Refresh token lưu dạng hash, thuộc một family, dùng một lần.
- Reuse detection là thứ biến rotation thành lá chắn thật, không chỉ là thủ tục.
- Logout, đổi mật khẩu, mất thiết bị: thu hồi ngay, đừng chờ hết hạn.
Bài tiếp theo đi vào phần còn lại của câu hỏi "ai được làm gì": phân quyền theo vai trò (RBAC) ngay ở tầng API, nơi access token vừa cấp sẽ được dùng để quyết định cho qua hay chặn.