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}
expires_in: 900: access token berlaku 900 detik, yaitu 15 menit. Token di contoh ini ditulis pendek (A1,R1) supaya gampang dilacak; aslinya string panjang.HttpOnly: JavaScript di halaman gak bisa membaca cookie ini (document.cookiegak menampilkannya), tapi browser tetap mengirimnya otomatis.Path=/auth/refresh: browser cuma menyertakan cookie ini ke/auth/refresh, gak ke request lain.Max-Age=2592000: 30 hari dalam detik.
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
}
credentials: "include"menyuruh browser menyertakan cookie, juga kalau API-nya di origin lain.refreshOnce()membuat promise refresh hanya kalau belum ada yang berjalan. Request lain yang ikut dapat401menunggu promise yang sama..finally(...)mengosongkanrefreshingsetelah selesai, berhasil atau gagal, supaya kedaluwarsa berikutnya bisa refresh lagi.return send()cuma sekali. Kalau hasil ulangannya masih401, function selesai, gak muter terus./auth/refreshdipanggil denganfetchbiasa, bukanfetchWithAuth. Kalau lewatfetchWithAuth,401dari refresh memicu refresh lagi, tanpa akhir.
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');
});
- Database cuma menyimpan hash refresh token. Kalau database bocor, token-nya gak ikut bocor.
whereNull('used_at')danexpires_atmenolak token yang sudah dipakai atau kedaluwarsa.AccessToken::for(...)adalah helper buatan aplikasi ini yang membuat access token 15 menit, misalnya PASETO seperti di lesson sebelumnya.- Argumen
->cookie(...)berurutan: nama, isi, umur dalam menit (30 hari), path, domain,Secure,HttpOnly, raw,SameSite.
⚠️ Jebakan yang sering ketemu
- Refresh token di
localStorage. Semua script di halaman bisa membacanya, dan umurnya 30 hari. Taruh di cookieHttpOnly. - Tiap request refresh sendiri. Dengan rotation, refresh kedua dan seterusnya ditolak, dan user terlempar ke login. Pakai satu promise refresh bersama.
- Mengulang tanpa batas. Ulangi request sekali saja, dan jangan panggil
/auth/refreshlewatfetchWithAuth. - Refresh untuk
403.403artinya izinnya kurang, bukan token-nya habis. Refresh gak menolong, tampilkan "akses ditolak".
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.