Refresh Token Rotation — Auth

Refresh Token Rotation adalah strategi keamanan di mana setiap kali refresh token digunakan, token tersebut langsung diinvalidasi dan digantikan dengan…

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: R1 sudah dipakai jam 07.15, dan Siti sekarang memegang R3. Jam 07.45 datang request refresh yang membawa R1 lagi. Server cuma menolak R1, atau R3 milik 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) };
}

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),
]);

⚠️ Jebakan yang sering ketemu

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.

Yang akan kamu pelajari