Refresh token rotation artinya setiap kali refresh token dipakai, server memberi refresh token baru dan mematikan yang lama. Satu refresh token cuma berlaku untuk satu kali tukar. Bayangin karcis parkir langganan yang diganti setiap kali kamu keluar gerbang: karcis lama dicap "terpakai" dan kamu dapat karcis baru bernomor berikutnya. Kalau karcis yang sudah dicap muncul lagi di gerbang, petugas tahu ada salinannya. Dia gak bisa tahu siapa pemilik aslinya, jadi semua karcis dari rangkaian itu dibatalkan, termasuk yang baru.
🧪 Tebakan cepat:
R1sudah dipakai jam 07.15, dan Siti sekarang memegangR3. Jam 07.45 datang request refresh yang membawaR1lagi. Server cuma menolakR1, atauR3milik Siti ikut mati? Tebak dulu, jawabannya ada di bagian "Token lama muncul lagi".
Satu refresh, satu token baru
Lesson Token Refresh Flow sudah membangun dasarnya: access token 15 menit di memori, refresh token 30 hari di cookie HttpOnly, dan POST /auth/refresh untuk menukarnya. Di lesson ini kita lihat tabel di server yang membuat rotation bisa dicek. Tiap refresh token satu baris di tabel refresh_tokens:
| Kolom | Isinya | Gunanya |
|---|---|---|
token_hash |
SHA-256 dari token | token aslinya gak pernah disimpan |
user_id |
pemiliknya | |
family_id |
id rangkaian token dari satu login | untuk mencabut satu rangkaian sekaligus |
expires_at |
kapan habis | 30 hari setelah diterbitkan |
used_at |
kapan ditukar | kosong berarti belum dipakai |
revoked_at |
kapan dicabut | kosong berarti belum dicabut |
Family adalah semua refresh token yang lahir dari satu login: R1 dari login, R2 dari menukar R1, R3 dari menukar R2, dan seterusnya. Login baru membuat family baru.
Satu kali refresh mengubah dua baris: token yang dikirim dapat used_at, dan token baru lahir di family yang sama. Token yang used_at-nya terisi gak bisa ditukar lagi.
Token lama muncul lagi
Siti login Jumat, 9 Oktober 2026, pukul 07.00. Ini yang dicatat server, persis seperti di Lab:
| Jam | Request | Jawaban | Yang berubah di tabel |
|---|---|---|---|
| 07.00 | POST /auth/login |
200, A1 dan R1 |
R1 lahir di family F1 |
| 07.15 | POST /auth/refresh, cookie R1 |
200, A2 dan R2 |
R1 dapat used_at 07.15 |
| 07.30 | POST /auth/refresh, cookie R2 |
200, A3 dan R3 |
R2 dapat used_at 07.30 |
| 07.45 | POST /auth/refresh, cookie R1 |
401 |
R1 sudah dipakai: R1, R2, R3 dapat revoked_at 07.45 |
| 08.00 | POST /auth/refresh, cookie R3 (Siti) |
401 |
R3 sudah dicabut, Siti login ulang dan dapat family F2 |
Dari sisi server, R1 jam 07.45 artinya ada dua pihak yang memegang rangkaian token yang sama. Siti sudah menukar R1 jam 07.15, jadi siapa pun yang menyodorkan R1 sekarang memegang salinannya. Server gak bisa tahu mana yang asli. Jalan yang aman: cabut seluruh family, termasuk R3 yang sedang dipegang Siti. Itu jawaban tebakannya: R3 ikut mati. Siti cukup login ulang. Pemegang salinannya gak bisa.
Banyak server memilih lebih keras lagi: cabut semua refresh token milik user itu, di semua perangkat, lalu kirim email pemberitahuan. Minimal, family-nya yang dicabut.
Jawaban ke client tetap 401 dengan pesan biasa, {"message": "Unauthenticated."}. Detailnya ("reuse terdeteksi, family F1 dicabut") masuk ke log server, bukan ke response.
Satu hal yang gak ikut dicabut: access token yang sudah terbit. Server gak mencatatnya, jadi dia tetap berlaku sampai habis, paling lama 15 menit. Itu sebabnya umurnya dibuat pendek.
Kode server-nya
Ini model server di Lab. Waktunya dalam menit sejak Jumat 07.00, dan token ditulis pendek (R1, R2) supaya gampang dilacak:
const DAY = 24 * 60;
const store = new Map(); // token -> { userId, familyId, expiresAt, usedAt, revokedAt }
function issue(userId, familyId, now) {
const token = "R" + (store.size + 1); // aslinya 32 byte acak, dan database cuma menyimpan hash-nya
store.set(token, { userId, familyId, expiresAt: now + 30 * DAY, usedAt: null, revokedAt: null });
return token;
}
function revokeFamily(familyId, now) {
for (const entry of store.values()) {
if (entry.familyId === familyId && entry.revokedAt === null) entry.revokedAt = now;
}
}
function refresh(token, now) {
const entry = store.get(token);
if (!entry || entry.revokedAt !== null || entry.expiresAt <= now) return { status: 401 };
if (entry.usedAt !== null) {
revokeFamily(entry.familyId, now); // dipakai dua kali: cabut seluruh family
return { status: 401, reuse: true };
}
entry.usedAt = now;
return { status: 200, refreshToken: issue(entry.userId, entry.familyId, now) };
}
- Urutan cek: gak dikenal, sudah dicabut, atau kedaluwarsa ditolak biasa. Baru setelah itu
usedAt: token sah yang sudah pernah ditukar berarti dipakai ulang. entry.usedAt = nowditulis sebelum token baru dibuat. Token lama mati di langkah yang sama.- Token baru mewarisi
familyIdyang lama. Itu yang membuatrevokeFamilybisa menemukan semuanya.
Latihan: family = semua token milik user itu
Latihannya versi yang lebih kecil. store adalah Map dengan key string token dan value { userId, used, valid }. Gak ada kolom family_id, jadi di latihan ini family-nya adalah semua entry dengan userId yang sama. rotateTokens(refreshToken, store) punya empat cabang, diperiksa berurutan:
| Keadaan token | Yang dilakukan | Yang dikembalikan |
|---|---|---|
gak ada di store |
gak mengubah apa pun | { success: false, error: "invalid" } |
valid: false (sudah dicabut) |
gak mengubah apa pun | { success: false, error: "revoked" } |
used: true |
valid: false di semua entry dengan userId yang sama |
{ success: false, error: "reuse_detected" } |
| belum dipakai | used: true, lalu entry baru |
{ success: true, newAccessToken, newRefreshToken } |
Nama token baru: "access_" + userId + "_" + (store.size + 1) dan "refresh_" + userId + "_" + (store.size + 1), dihitung sebelum entry baru ditambahkan. store di awal berisi satu token, jadi rotasi pertama dari refresh_user42_1 menghasilkan refresh_user42_2. Entry barunya { userId, used: false, valid: true }. Jadi kalau refresh_user42_1 dipakai ulang, refresh_user42_2 yang sedang dipegang client ikut mati, dan menukarnya dijawab revoked. Token milik user lain gak tersentuh.
📌 Di Laravel
Endpoint dari lesson Token Refresh Flow ditambah satu cabang untuk pemakaian ulang:
$token = RefreshToken::where('token_hash', hash('sha256', (string) $request->cookie('refresh_token')))->first();
abort_if($token === null || $token->revoked_at !== null || $token->expires_at->isPast(), 401, 'Unauthenticated.');
if ($token->used_at !== null) {
RefreshToken::where('family_id', $token->family_id)->whereNull('revoked_at')->update(['revoked_at' => now()]);
Log::warning('refresh_token.reuse', ['user_id' => $token->user_id, 'family_id' => $token->family_id]);
abort(401, 'Unauthenticated.');
}
$token->update(['used_at' => now()]);
$plain = Str::random(64);
$token->user->refreshTokens()->create([
'family_id' => $token->family_id, // token baru tetap di family yang sama
'token_hash' => hash('sha256', $plain),
'expires_at' => now()->addDays(30),
]);
expires_atdi-cast ke datetime di model, jadiisPast()bisa dipakai.- Saat login,
family_iddiisiStr::uuid()baru. Untuk mencabut semua milik user, gantiwhere('family_id', ...)denganwhere('user_id', ...).
⚠️ Jebakan yang sering ketemu
- Menyimpan token apa adanya. Simpan
hash('sha256', $token). Kalau database bocor, token-nya gak ikut bocor. - Mencabut token lama saja. Saat pemakaian ulang, yang paling penting justru token terbaru, karena itulah yang mungkin dipegang salinannya. Cabut seluruh family.
- Dua tab refresh bersamaan. Tab kedua mengirim token yang baru saja ditukar tab pertama, dan server menganggapnya pemakaian ulang. Di client, pakai satu promise refresh bersama (lesson Token Refresh Flow). Sebagian server juga memberi jeda beberapa detik untuk token yang baru saja dipakai.
- Memberi tahu client terlalu banyak. Response tetap
401biasa. Catat detailnya di log, dan kirim pemberitahuan ke pemilik akun.
TL;DR: Rotation: setiap POST /auth/refresh menandai token lama used_at dan menerbitkan token baru di family yang sama, jadi satu refresh token cuma bisa ditukar sekali. Token yang sudah dipakai muncul lagi artinya ada dua pemegang: cabut seluruh family (atau semua token milik user itu), jawab 401 biasa, dan catat di log. Tabelnya menyimpan token_hash, user_id, family_id, expires_at, used_at, dan revoked_at. Access token yang sudah terbit tetap berlaku sampai habis, jadi umurnya pendek. Di latihan: invalid untuk token yang gak dikenal, reuse_detected plus valid: false untuk pemakaian ulang, dan nama token baru dengan store.size + 1.