Một refresh token bị đánh cắp còn giá trị hơn mật khẩu bị đánh cắp. Nó không hết hạn trong nhiều tuần, không bao giờ được gõ ở chỗ người dùng nhận ra, và trên phần lớn hệ thống thì dùng lại được vô số lần. Xoay token làm nó gần như mất giá trị.
Cái token không bao giờ chết
Cách làm thường thấy: cấp một access token ngắn hạn và một refresh token dài hạn. Access token hết hạn sau mười lăm phút; refresh token sống ba mươi ngày và đổi lấy access token mới bất cứ khi nào cần.
Lỗ hổng nằm ở đúng chữ bất cứ khi nào. Nếu cùng một refresh token đổi được nhiều lần, thì ai cầm bản sao cũng có ba mươi ngày truy cập, và không gì trong giao thức phân biệt được bản sao với bản gốc. Người dùng thật không nhận ra gì cả, vì phiên của họ vẫn chạy bình thường.
Xoay token: mỗi token đổi đúng một lần
Xoay token đổi luật thành mỗi token dùng một lần. Mỗi lần đổi trả về refresh token mới và đánh dấu token cũ đã tiêu:
const record = await tx.refreshToken.findUnique({ where: { hash } })
if (!record) throw new InvalidToken()
await tx.refreshToken.update({ where: { id: record.id }, data: { usedAt: new Date() } })
const next = await tx.refreshToken.create({ data: { familyId: record.familyId, hash: newHash } })
Cửa sổ tấn công co từ ba mươi ngày xuống khoảng cách giữa hai lần refresh — thường là vài phút. Token chép được hôm thứ Hai thì thứ Ba đã tiêu rồi.
Việc dùng lại chính là tín hiệu
Chỉ xoay thôi đã làm tăng chi phí tấn công. Nhưng thứ khiến nó thực sự hữu ích là điều mà lần dùng thứ hai của một token đã tiêu nói cho ta biết.
Chỉ có hai khả năng. Client tự đua với chính nó hoặc thử lại sau khi mất phản hồi — hoặc ai đó đang replay token không thuộc về họ. Bạn không phân biệt được từ chính request, và không sao cả, vì phản ứng an toàn cho cả hai là như nhau.
Mọi token cấp ra từ một lần đăng nhập dùng chung một familyId. Khi phát hiện dùng lại, thu hồi cả họ:
if (record.usedAt) {
await tx.refreshToken.updateMany({
where: { familyId: record.familyId, revokedAt: null },
data: { revokedAt: new Date() },
})
throw new TokenReuseDetected()
}
Kẻ tấn công mất phiên. Người dùng thật cũng mất, phải đăng nhập lại và hơi khó chịu. Đánh đổi đó luôn đáng.
Lưu hash, đừng lưu token
Bảng refresh token là một danh sách credential dài hạn. Hãy đối xử với nó như với mật khẩu: lưu sha256(token), tra theo hash, và làm cho một bản dump database trở nên vô dụng.
Khác với hash mật khẩu, hash này không cần chậm. Refresh token là giá trị ngẫu nhiên entropy cao, không phải cụm từ đoán được, nên bcrypt chẳng mua thêm gì mà lại tốn độ trễ ở mọi lần refresh.
Token nằm ở đâu
Tất cả những điều trên vô nghĩa nếu token nằm ở chỗ script đọc được. httpOnly và Secure giữ nó ngoài tầm JavaScript; SameSite=Strict giữ nó khỏi request cross-site. Giới hạn phạm vi cookie về đúng endpoint refresh để nó không đi kèm mọi lời gọi API — credential nào ít di chuyển thì ít bị lộ.
Chỗ khó chịu: đua request
Client bắn hai lệnh refresh cùng lúc sẽ kích hoạt cảnh báo dùng lại một cách hoàn toàn chính đáng. Có hai cách xử lý, và cách thứ hai tốt hơn vẻ ngoài của nó.
Cách thứ nhất là một khoảng ân hạn ngắn: chấp nhận token đã tiêu trong vài giây và trả về token đã thay thế nó. Nó khử được báo động giả, nhưng cũng cho kẻ tấn công vài giây chồng lấn.
Cách thứ hai là đừng đua ngay từ đầu — một khoá single-flight ở client để các lỗi 401 đồng thời cùng chờ một lần refresh. Tôi chọn cách này. Nó sửa nguyên nhân thay vì nới luật, và loại bỏ một ngoại lệ mà nếu không thì phải bảo vệ mãi mãi.
Những gì cách này không giải quyết
Xoay token giới hạn thiệt hại khi refresh token bị lộ. Nó không xử lý được access token bị lộ trong thời gian còn hiệu lực — đó chính là lý do phải giữ thời hạn đó ngắn. Nó không xử lý được thiết bị bị chiếm, vì khi đó kẻ tấn công xoay token song song cùng người dùng. Và nó không xử lý được phishing, thứ trao cả phiên đi một cách rất "hợp lệ".
Đây là một biện pháp, không phải một chiến lược. Nhưng nó là biện pháp biến một cuộc xâm nhập kéo dài cả tháng thành vài phút, với khoảng năm mươi dòng code.