Token Refresh Flow — Auth

Masalah: JWT access token harus punya expiry pendek (15-30 menit) untuk keamanan. Tapi kita tidak mau user harus login ulang setiap 15 menit. Solusi: Refresh…

Access token yang berumur pendek itu aman, tapi merepotkan: masa Siti harus login ulang tiap 15 menit? Refresh flow menyelesaikannya dengan dua token. Access token berumur pendek dan ikut di setiap request. Refresh token berumur panjang dan cuma dipakai di satu tempat: menukar access token baru. Bayangin gedung kantor. Di lobi kamu dapat kartu tamu yang berlaku 15 menit, dan kartu itu yang kamu tempel di setiap pintu. Habis waktunya, kamu ke resepsionis, menunjukkan kartu langganan bulananmu (yang gak pernah kamu tempel di pintu mana pun), lalu dapat kartu tamu baru. Kartu tamu itu access token, kartu langganan itu refresh token, dan resepsionisnya endpoint POST /auth/refresh.

🧪 Tebakan cepat: dashboard Portal Sekolah mengirim 3 request sekaligus, tepat setelah access token Siti kedaluwarsa. Ketiganya dapat 401. Berapa kali client sebaiknya memanggil /auth/refresh: 1 kali atau 3 kali? Tebak dulu, jawabannya ada di bagian "Tiga request, satu refresh".

Dua token, dua umur

Di Portal Sekolah, aplikasi contoh track ini, login Siti dijawab dengan dua token:

Access token Refresh token
Umur 15 menit 30 hari
Bentuk token bertanda tangan (JWT atau PASETO) string acak tanpa isi (opaque)
Disimpan client di variabel JavaScript (memori) cookie HttpOnly
Dikirim ke setiap request API, di header Authorization cuma POST /auth/refresh
Kalau bocor berlaku paling lama 15 menit bisa dicabut, karena server mencatatnya di database

Jawaban login-nya:

HTTP/1.1 200 OK
Set-Cookie: refresh_token=R1; Max-Age=2592000; Path=/auth/refresh; Secure; HttpOnly; SameSite=Strict
Content-Type: application/json

{"access_token": "A1", "token_type": "Bearer", "expires_in": 900}

Access token sengaja gak disimpan di localStorage, karena semua script di halaman bisa membacanya. Akibatnya, access token hilang saat halaman di-reload. Gak masalah: begitu aplikasi dibuka lagi, client memanggil /auth/refresh sekali, dan cookie-nya masih ada.

Kenapa gak satu token umur panjang saja? Kalau access token bocor, dampaknya terbatas: 15 menit kemudian token itu gak berlaku lagi. Refresh token yang umurnya panjang jarang keluar (cuma ke satu endpoint), dan server bisa mencabutnya.

Alurnya, di jam virtual

Siti login Jumat, 9 Oktober 2026, pukul 07.00. Ini yang terjadi, persis seperti di Lab:

Jam Request Jawaban server Yang dilakukan client
07.00 POST /auth/login 200, access token A1 (habis 07.15), cookie R1 (habis 8 Nov 07.00) simpan A1 di memori
07.10 GET /students/1/grades dengan A1 200 tampilkan rapor
07.20 GET /students/1/grades dengan A1 401, token kedaluwarsa jangan ke login dulu, refresh
07.20 POST /auth/refresh dengan cookie R1 200, A2 (habis 07.35), cookie R2 simpan A2
07.20 GET /students/1/grades dengan A2 200 rapor tampil, Siti gak sadar apa-apa

401 jam 07.20 datang dengan header yang menjelaskan sebabnya:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer error="invalid_token", error_description="The access token expired"

Jadi saat menerima 401, langkah pertama client adalah mencoba refresh, lalu mengulang request-nya. Baru kalau refresh-nya juga gagal (refresh token habis atau dicabut), Siti diarahkan ke halaman login.

Kode client: fetchWithAuth

Semua request API lewat satu function. Dia memasang access token, dan kalau dapat 401, dia refresh lalu mengulang sekali.

let accessToken = null; // di memori, bukan localStorage
let refreshing = null; // promise refresh yang sedang jalan, dipakai bersama

async function refreshAccessToken() {
  const res = await fetch("/auth/refresh", { method: "POST", credentials: "include" });
  if (!res.ok) throw new Error("refresh gagal");
  accessToken = (await res.json()).access_token;
}

function refreshOnce() {
  if (!refreshing) {
    refreshing = refreshAccessToken().finally(() => {
      refreshing = null;
    });
  }
  return refreshing;
}

async function fetchWithAuth(url, options = {}) {
  const send = () =>
    fetch(url, { ...options, headers: { ...options.headers, Authorization: `Bearer ${accessToken}` } });

  const res = await send();
  if (res.status !== 401) return res;
  try {
    await refreshOnce();
  } catch {
    window.location.assign("/login"); // refresh token habis atau dicabut
    return res;
  }
  return send(); // diulang sekali saja
}

Tiga request, satu refresh

Jam 07.20, dashboard Siti mengirim tiga request sekaligus: rapor (/students/1/grades), jadwal (/students/1/schedule), dan pengumuman (/announcements). Ketiganya membawa A1, ketiganya dapat 401.

Kalau tiap request refresh sendiri, ketiganya membawa cookie yang sama, R1, karena dikirim sebelum jawaban pertama datang:

Refresh Cookie Jawaban
ke-1 R1 200, dapat A2 dan R2. R1 ditandai sudah dipakai
ke-2 R1 401, R1 sudah dipakai
ke-3 R1 401, R1 sudah dipakai

Refresh ke-2 gagal, jadi fetchWithAuth melempar Siti ke halaman login, padahal sesinya sehat. Dengan refreshOnce(), request ke-2 dan ke-3 menunggu promise yang sama: 1 refresh, 3 request diulang, 3 kali 200. Itu jawaban tebakannya: cukup sekali.

Refresh token rotation

Kenapa R1 ditolak di refresh ke-2? Karena server Portal Sekolah memakai refresh token rotation: setiap kali refresh token dipakai, server memberi refresh token baru dan menandai yang lama sudah gak berlaku. Satu refresh token cuma bisa dipakai sekali. Kalau token lama muncul lagi, berarti ada yang janggal (token itu mungkin dicuri), dan server bisa mencabut semua sesi user itu. Cara mendeteksinya dibangun di lesson Refresh Token Rotation.

📌 Endpoint refresh di Laravel

Route::post('/auth/refresh', function (Request $request) {
    $token = RefreshToken::where('token_hash', hash('sha256', (string) $request->cookie('refresh_token')))
        ->whereNull('used_at')
        ->where('expires_at', '>', now())
        ->first();
    abort_if($token === null, 401, 'Unauthenticated.');

    $token->update(['used_at' => now()]); // rotation: token lama gak berlaku lagi
    $plain = Str::random(64);
    $token->user->refreshTokens()->create([
        'token_hash' => hash('sha256', $plain),
        'expires_at' => now()->addDays(30),
    ]);

    return response()
        ->json(['access_token' => AccessToken::for($token->user), 'token_type' => 'Bearer', 'expires_in' => 900])
        ->cookie('refresh_token', $plain, 60 * 24 * 30, '/auth/refresh', null, true, true, false, 'strict');
});

⚠️ Jebakan yang sering ketemu

TL;DR: Access token pendek (15 menit) di memori ikut di setiap request. Refresh token panjang (30 hari, opaque) di cookie HttpOnly cuma dikirim ke POST /auth/refresh. Kalau access token bocor, dampaknya terbatas. Request dapat 401: refresh sekali, lalu ulangi request-nya sekali. Refresh juga 401: arahkan ke login. Banyak request kedaluwarsa bersamaan tetap cukup satu refresh, lewat satu promise bersama. Rotation memberi refresh token baru tiap kali dipakai, jadi token lama gak bisa dipakai lagi.

Yang akan kamu pelajari