SPA (Single Page Application, misalnya React atau Vue) dan aplikasi mobile sama-sama harus menyimpan tanda login di sisi client. Pertanyaannya: di mana, supaya aman tapi gak bikin user login ulang terus? Bayangin rumah kos. localStorage itu kunci yang digantung di dinding ruang tamu: siapa pun yang masuk rumah bisa mengambilnya, termasuk tamu yang gak kamu kenal. Memori JavaScript itu kunci di saku: aman, tapi ikut hilang begitu kamu ganti baju. Cookie HttpOnly itu kunci yang dipegang penjaga kos: dia membukakan pintu untukmu setiap kali, tapi kuncinya gak pernah diserahkan ke tanganmu. Di HP, Keychain (iOS) dan Keystore (Android) itu brankas pribadi milik satu aplikasi.
🧪 Tebakan cepat: SPA Portal Sekolah menyimpan access token Siti di variabel JavaScript, bukan di localStorage. Siti menekan reload. Dia harus login ulang, atau tetap masuk? Tebak dulu, jawabannya ada di bagian "Reload: memori kosong, cookie masih ada".
Tiga tempat menyimpan token di browser
| Tempat | Bisa dibaca script di halaman? | Bertahan saat reload? | Dikirim otomatis? |
|---|---|---|---|
localStorage |
ya, script mana pun | ya, sampai dihapus | gak |
| Memori (variabel) | cuma kode yang memegang variabelnya | gak | gak |
Cookie HttpOnly |
gak sama sekali | ya, sampai kedaluwarsa | ya, ke domainnya |
"Script di halaman" termasuk script pihak ketiga dan script yang masuk lewat celah XSS. Token di localStorage bisa dibaca dan dibawa pergi kapan saja, dan berlaku sampai kedaluwarsa. Token di memori gak tersimpan di mana pun, jadi gak ada yang bisa diambil setelah tab ditutup. Cookie HttpOnly gak terlihat oleh document.cookie, tapi karena dikirim otomatis, endpoint yang memakainya butuh perlindungan CSRF (lesson CSRF Protection). Perbandingan lengkapnya ada di lesson Secure Storage di Browser di track Security.
Pola Portal Sekolah: memori + cookie HttpOnly
Persis seperti di lesson Token Refresh Flow: access token 15 menit di memori, refresh token 30 hari di cookie HttpOnly. Tempat simpan access token-nya sebuah closure:
function createTokenStore() {
let accessToken = null; // di closure: gak di localStorage, hilang saat reload
return {
setAccessToken(token) { accessToken = token; }, // { value, expiresAt } dalam milidetik
getAccessToken() { return accessToken; },
clearTokens() { accessToken = null; },
isAuthenticated() { return accessToken !== null && accessToken.expiresAt > Date.now(); },
needsRefresh(token) { return token.expiresAt - Date.now() < 60000; }, // sisa kurang dari 60 detik
};
}
Siti login pukul 07.00.00, dan token A1 habis 07.15.00 (expiresAt 1791504900000):
| Jam | isAuthenticated() |
needsRefresh(token) |
Yang dilakukan app |
|---|---|---|---|
| sebelum login | false |
tampilkan form login | |
| 07.00.00 | true |
false, sisa 900.000 ms |
panggil API seperti biasa |
| 07.14.30 | true |
true, sisa 30.000 ms |
refresh dulu, baru panggil API |
| 07.15.00 | false |
true |
token habis: refresh |
accessToken !== null && ...mengembalikan boolean. Kalau ditulisaccessToken && ..., hasilnyanullsaat belum login, bukanfalse.needsRefreshmembandingkan milidetik: 60 detik = 60000. Refresh sebelum token habis berarti request gak pernah dapat401di tengah jalan.
Reload: memori kosong, cookie masih ada
Reload mengosongkan semua variabel, jadi A1 hilang. Tapi cookie refresh_token masih ada di browser. Begitu aplikasi dimuat, ia memanggil /auth/refresh sekali:
const store = createTokenStore();
async function initAuth() {
const res = await fetch("/auth/refresh", { method: "POST", credentials: "include" }); // cookie ikut otomatis
if (!res.ok) return false; // belum login, atau refresh token habis
const { access_token, expires_in } = await res.json();
store.setAccessToken({ value: access_token, expiresAt: Date.now() + expires_in * 1000 });
return true;
}
Server menjawab dengan access token baru A2, dan Siti tetap masuk tanpa melihat form login. Itu jawaban tebakannya.
Laravel Sanctum: cookie sesi atau API token
Laravel Sanctum punya dua mode, dan memilihnya gampang:
Mode SPA (cookie sesi), untuk SPA milikmu sendiri di domain yang sama, misalnya portal.sekolah.test dan API di api.sekolah.test. SPA gak memegang token sama sekali. Sanctum memakai session Laravel biasa:
// bootstrap/app.php
->withMiddleware(function (Middleware $middleware): void {
$middleware->statefulApi(); // request dari domain stateful memakai cookie sesi
})
// .env
// SANCTUM_STATEFUL_DOMAINS=portal.sekolah.test
// SESSION_DOMAIN=.sekolah.test
axios.defaults.withCredentials = true; // kirim cookie ke api.sekolah.test
axios.defaults.withXSRFToken = true; // salin cookie XSRF-TOKEN ke header X-XSRF-TOKEN
await axios.get("https://api.sekolah.test/sanctum/csrf-cookie"); // 204, set cookie XSRF-TOKEN
await axios.post("https://api.sekolah.test/login", { email: "[email protected]", password: "rahasia123" });
const { data } = await axios.get("https://api.sekolah.test/api/user"); // { id: 1, name: "Siti", ... }
/sanctum/csrf-cookiemenjawab204dan memasang cookieXSRF-TOKEN, yang memang boleh dibaca JavaScript, plus cookie sesiportal_sekolah_sessionyangHttpOnly.- Setelah login, cookie sesi ikut di setiap request. Route API dilindungi
->middleware('auth:sanctum'). Di config CORS,supports_credentialsharustrue.
Mode API token, untuk aplikasi mobile dan client pihak ketiga. $user->createToken('HP Siti')->plainTextToken menghasilkan string seperti 1|k3J9.... Database cuma menyimpan hash SHA-256-nya, client mengirimnya di Authorization: Bearer, dan $token->delete() mencabutnya.
BFF: token tetap di server
Kalau SPA harus login lewat OAuth (misalnya akun Google sekolah), polanya BFF (Backend for Frontend): server kecil khusus untuk satu frontend. BFF yang menjalankan alur OAuth dan menyimpan access token serta refresh token di session server. SPA cuma memegang cookie sesi HttpOnly ke BFF, dan BFF yang menempelkan token ke request API. Token gak pernah sampai ke JavaScript. Mode SPA Sanctum sebenarnya bentuk sederhana dari ide ini: API-mu sendiri sekaligus jadi BFF-nya.
Aplikasi mobile: tanpa client_secret, simpan di Keychain atau Keystore
Aplikasi mobile adalah public client: file aplikasinya (APK atau IPA) ada di HP semua orang dan bisa dibongkar, jadi client_secret apa pun yang ditanam di dalamnya bisa diambil. Karena itu login OAuth dari aplikasi mobile memakai PKCE tanpa client_secret: aplikasi membuat code_verifier acak untuk setiap login dan mengirim code_challenge-nya (caranya di lesson OAuth 2.0 Flow). Tiga aturan lain:
- Buka halaman login di browser sistem (ASWebAuthenticationSession di iOS, Custom Tabs di Android), bukan WebView. Di WebView, aplikasinya bisa membaca apa yang diketik user.
- Redirect kembali ke aplikasi lewat URI yang terdaftar, misalnya
portalsekolah://callback. - Simpan refresh token atau token Sanctum di Keychain (iOS) atau Keystore (Android), misalnya lewat
expo-secure-storeatauflutter_secure_storage. Jangan di AsyncStorage atau SharedPreferences biasa, yang isinya file teks.
⚠️ Jebakan yang sering ketemu
- Token di
localStorage. Script mana pun di halaman bisa membacanya. Simpan di memori, dan biarkan cookieHttpOnlyyang bertahan. isAuthenticatedmengembalikannull.accessToken && ...memberinullsaat belum login. TulisaccessToken !== null && ....- Ambang refresh dalam detik.
expiresAt - Date.now() < 60artinya 60 milidetik. Tulis60000. - Mode SPA Sanctum di situs yang beda. Dengan
SameSite=Lax, cookie sesi gak ikut di request dariportal-sekolah.comkeapi.sekolah.test. Untuk situs beda atau aplikasi mobile, pakai API token. client_secretdi aplikasi mobile. Itu bukan rahasia lagi. Pakai PKCE.
TL;DR: Di SPA, simpan access token di memori (closure seperti createTokenStore) dan refresh token di cookie HttpOnly; jangan di localStorage. isAuthenticated() = accessToken !== null && accessToken.expiresAt > Date.now(), needsRefresh(token) = sisa umur di bawah 60000 ms. Saat reload, memori kosong tapi cookie masih ada, jadi app memanggil /auth/refresh dan user tetap masuk. Laravel Sanctum: mode SPA memakai cookie sesi (/sanctum/csrf-cookie, statefulApi(), XSRF-TOKEN) untuk SPA di domain yang sama, mode API token (createToken, Bearer) untuk mobile. BFF menyimpan token OAuth di server dan memberi SPA cookie sesi saja. Aplikasi mobile adalah public client: tanpa client_secret, pakai PKCE, login di browser sistem, simpan token di Keychain atau Keystore.