Session membawa keterbatasan praktis: cookie session umumnya ikut lenyap saat browser ditutup, dan data di server dibersihkan setelah periode idle tertentu. Pengguna aplikasi nyata tidak mau login ulang setiap kali membuka laptop — mereka mencentang kotak “ingat saya” dan berharap login bertahan berminggu-minggu. Remember-me memenuhi harapan itu dengan cookie berumur panjang, tetapi implementasinya yang salah justru membuka celah keamanan besar. Chapter ini merancang token remember-me yang benar: dibangkitkan dari keacakan kriptografis, disimpan sebagai hash di database, diverifikasi konstan-waktu, dan dirotasi setiap kali dipakai.
Batas Umur Session dan Kebutuhan Remember-Me #
Ada dua alasan session tidak cocok menjadi ingatan jangka panjang. Pertama, cookie PHPSESSID secara bawaan adalah session cookie — tanpa atribut expires, browser membuangnya begitu aplikasi browser ditutup. Kedua, meskipun cookie bertahan, PHP membersihkan data session di server yang melewati session.gc_maxlifetime (bawaan 1440 detik idle), sebagaimana kita bahas di chapter Sessions
dan Session Configuration
.
Remember-me bekerja di jalur yang berbeda: sebuah cookie khusus berumur panjang (biasanya 30 hari) yang isinya bukan ID session, melainkan token acak. Saat pengguna datang tanpa session aktif, aplikasi membaca cookie tersebut, memeriksa token-nya di database, dan bila sah membuat session baru — pengguna merasa langsung masuk tanpa menyentuh form login. Karena token hidup di database, aplikasi dapat mencabutnya kapan pun, sesuatu yang tidak mungkin dilakukan pada session cookie biasa.
Mengapa Password Tidak Boleh Disimpan di Cookie #
Ide yang pertama kali terlintas biasanya sederhana namun berbahaya: simpan email dan password di cookie, lalu login otomatis dengannya. Praktik ini dilarang keras karena beberapa alasan. Password di cookie tersimpan sebagai nilai yang bisa dibaca — cookie dikirim dan disimpan di sisi browser, jadi siapa pun yang membaca isi cookie (termasuk skrip berbahaya bila flag httponly tidak dipasang) memperoleh password mentah. Nilai tersebut juga bisa diputar ulang (replay) tanpa batas sampai password diganti, dan manusia cenderung memakai password yang sama di banyak layanan sehingga satu kebocoran menjalar kemana-mana.
Solusi yang benar adalah token acak yang tidak memiliki makna apa pun di luar aplikasi. Kebocoran token tetap berbahaya, tetapi kerugiannya terbatas: token hanya bisa dipakai pada satu akun di satu aplikasi, dan aplikasi dapat mencabutnya tanpa menuntut pengguna mengganti password.
Desain Token Selector dan Validator #
Token remember-me yang baik terdiri dari dua bagian yang dipisah titik dua: selector:validator. Bagian selector berfungsi sebagai alamat pencarian — aplikasi memakainya untuk mencari baris token di database lewat kolom berindeks. Bagian validator adalah rahasia yang dibandingkan dengan hash tersimpan. Pemisahan ini membuat database tidak perlu memindai seluruh tabel saat verifikasi, sekaligus memungkinkan perbandingan rahasia dilakukan dengan aman.
Kedua bagian dibangkitkan dari random_bytes() — sumber acak kriptografis yang sama dengan ID session. Fungsi bin2hex() mengubah byte mentah menjadi karakter heksadesimal agar aman ditempatkan di cookie:
<?php
$selector = bin2hex(random_bytes(16));
$validator = bin2hex(random_bytes(32));
echo 'Panjang selector (hex): ' . strlen($selector) . " karakter\n";
echo 'Panjang validator (hex): ' . strlen($validator) . " karakter\n";
echo 'Selector hanya berisi karakter hex: ' . var_export(ctype_xdigit($selector), true) . "\n";
echo 'Validator hanya berisi karakter hex: ' . var_export(ctype_xdigit($validator), true) . "\n";Output yang Dihasilkan:
Panjang selector (hex): 32 karakter
Panjang validator (hex): 64 karakter
Selector hanya berisi karakter hex: true
Validator hanya berisi karakter hex: trueNilai random_bytes() berbeda pada setiap eksekusi, jadi contoh di atas sengaja hanya mencetak sifat struktural yang selalu tetap: 16 byte menjadi 32 karakter heksadesimal, 32 byte menjadi 64 karakter. Perhatikan bahwa yang disimpan di database bukan validator mentah, melainkan hash('sha256', $validator) — persis filosofi password_hash() dari chapter Password Hashing
. Bila database bocor, penyerang memperoleh hash yang tidak bisa dipakai menyerang balik ke cookie, karena mencari prapeta SHA-256 dari 64 karakter acak tidaklah praktis.
Verifikasi dengan hash_equals #
Perbandingan validator harus memakai hash_equals(), bukan operator == atau ===. Operator pembanding biasa berhenti di karakter pertama yang berbeda, sehingga waktu eksekusinya membocorkan berapa banyak awalan yang benar — celah timing attack yang lama-lama memetahkan nilai rahasia. hash_equals() selalu memindai seluruh string sehingga waktunya konstan:
<?php
function hashValidator(string $validator): string
{
return hash('sha256', $validator);
}
// Saat login: database hanya menyimpan HASH dari validator
$validatorAsli = '9f86d081884c7d659a2feaa0c55ad015';
$hashTersimpan = hashValidator($validatorAsli);
// Request berikutnya: cookie membawa validator mentah dari browser
$validatorCookieAsli = '9f86d081884c7d659a2feaa0c55ad015';
$validatorCookiePalsu = '9f86d081884c7d659a2feaa0c55ad016';
echo 'Cookie asli: ' . var_export(hash_equals($hashTersimpan, hashValidator($validatorCookieAsli)), true) . "\n";
echo 'Cookie palsu: ' . var_export(hash_equals($hashTersimpan, hashValidator($validatorCookiePalsu)), true) . "\n";Output yang Dihasilkan:
Cookie asli: true
Cookie palsu: falseFungsi hashValidator() meng-hash dulu nilai yang datang dari cookie, baru hasilnya dibandingkan dengan hash tersimpan — kedua sisi harus dalam bentuk hash yang sama panjang. hash_equals() mengembalikan bool, dan nilainya false untuk perbedaan satu karakter saja, sesuai tampilan output.
Implementasi Penyimpanan Token di Database #
Sekarang kita rakit seluruh komponen menjadi alur utuh memakai SQLite sebagai tempat berpijak — skema dan logikanya sama persis di MySQL maupun PostgreSQL. Contoh ini sekaligus menunjukkan tiga peristiwa penting: pembuatan token saat login, verifikasi saat kunjungan berikutnya, dan pencabutan saat logout:
<?php
$pdo = new PDO('sqlite::memory:');
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$pdo->exec('CREATE TABLE remember_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
selector TEXT NOT NULL UNIQUE,
validator_hash TEXT NOT NULL,
expires_at TEXT NOT NULL
)');
function createRememberToken(PDO $pdo, int $userId, string $validator, string $expiresAt): string
{
$selector = bin2hex(random_bytes(16));
$pdo->prepare('INSERT INTO remember_tokens (user_id, selector, validator_hash, expires_at)
VALUES (?, ?, ?, ?)')
->execute([$userId, $selector, hash('sha256', $validator), $expiresAt]);
return $selector . ':' . $validator;
}
function findUserIdByToken(PDO $pdo, string $cookieValue, string $now): ?int
{
[$selector, $validator] = array_pad(explode(':', $cookieValue, 2), 2, '');
$stmt = $pdo->prepare('SELECT user_id, validator_hash, expires_at FROM remember_tokens WHERE selector = ?');
$stmt->execute([$selector]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false) {
return null; // selector tidak dikenal
}
if ($now > $row['expires_at']) {
return null; // token sudah kedaluwarsa
}
if (!hash_equals($row['validator_hash'], hash('sha256', $validator))) {
return null; // validator tidak cocok
}
return (int) $row['user_id'];
}
function rotateRememberToken(PDO $pdo, string $cookieLama, string $validatorBaru): string
{
$selectorLama = explode(':', $cookieLama, 2)[0];
$selectorBaru = bin2hex(random_bytes(16));
$pdo->prepare('UPDATE remember_tokens SET selector = ?, validator_hash = ? WHERE selector = ?')
->execute([$selectorBaru, hash('sha256', $validatorBaru), $selectorLama]);
return $selectorBaru . ':' . $validatorBaru;
}
// Login: buat token berumur 30 hari untuk user 42
$cookie = createRememberToken($pdo, 42, 'validator-demo-percobaan', '2026-02-14 08:00:00');
// Kunjungan 2026-01-20: token masih segar
$hasil = findUserIdByToken($pdo, $cookie, '2026-01-20 08:00:00');
echo 'Cookie asli: ' . ($hasil === null ? 'DITOLAK' : "diterima, user_id $hasil") . "\n";
// Selector sama tetapi validator diganti orang tidak bertanggung jawab
[$selector] = explode(':', $cookie, 2);
$cookiePalsu = $selector . ':validator-yang-dipalsukan';
$hasil = findUserIdByToken($pdo, $cookiePalsu, '2026-01-20 08:00:00');
echo 'Cookie palsu: ' . ($hasil === null ? 'DITOLAK' : "diterima, user_id $hasil") . "\n";
// Kunjungan 2026-03-01: melewati tanggal kedaluwarsa
$hasil = findUserIdByToken($pdo, $cookie, '2026-03-01 08:00:00');
echo 'Token kedaluwarsa: ' . ($hasil === null ? 'DITOLAK' : "diterima, user_id $hasil") . "\n";
// Rotasi: setiap login sukses lewat cookie, token diganti
$cookieBaru = rotateRememberToken($pdo, $cookie, 'validator-baru-setelah-rotasi');
$hasil = findUserIdByToken($pdo, $cookie, '2026-01-20 08:00:00');
echo 'Cookie lama setelah rotasi: ' . ($hasil === null ? 'DITOLAK' : "diterima, user_id $hasil") . "\n";
$hasil = findUserIdByToken($pdo, $cookieBaru, '2026-01-20 08:00:00');
echo 'Cookie baru setelah rotasi: ' . ($hasil === null ? 'DITOLAK' : "diterima, user_id $hasil") . "\n";
// Logout: hapus baris token dari database
$pdo->prepare('DELETE FROM remember_tokens WHERE selector = ?')
->execute([explode(':', $cookieBaru, 2)[0]]);
echo 'Token tersisa di database: ' . $pdo->query('SELECT COUNT(*) FROM remember_tokens')->fetchColumn() . "\n";Output yang Dihasilkan:
Cookie asli: diterima, user_id 42
Cookie palsu: DITOLAK
Token kedaluwarsa: DITOLAK
Cookie lama setelah rotasi: DITOLAK
Cookie baru setelah rotasi: diterima, user_id 42
Token tersisa di database: 0Mari kita bedah fungsi-fungsi utamanya:
createRememberToken(): membangkitkan selector segar, menyimpan hash validator beserta tanggal kedaluwarsa, lalu mengembalikan stringselector:validatoryang siap dikirim sebagai cookie.findUserIdByToken(): memecah cookie menjadi dua bagian, mencari baris berdasarkan selector, lalu menolak bertahap — selector tak dikenal, token kedaluwarsa, validator tak cocok — sebelum mengembalikanuser_id. Pemeriksaanexpires_atmemakai perbandingan string tanggal berformat ISO yang urutannya konsisten secara leksikografis.rotateRememberToken(): mengganti selector dan validator pada baris yang sama, sehingga cookie lama otomatis mati begitu cookie baru terbit.
Output membuktikan seluruh jalur: cookie asli diterima, validator palsu dan token kedaluwarsa ditolak, cookie lama mati setelah rotasi, dan logout mengosongkan tabel.
Rotasi, Pencurian Token, dan Logout #
Rotasi pada setiap pemakaian bukan hiasan — rotasi memberi aplikasi deteksi pencurian. Karena setiap pemakaian yang sah menghasilkan token baru, pemakaian token lama setelah rotasi hanya mungkin terjadi bila ada pihak lain yang menyalin cookie sebelum rotasi terjadi. Praktik terbaiknya: bila token yang sudah dirotasi ternyata dipakai lagi, anggap cookie dicuri dan hapus seluruh token milik user tersebut, lalu paksa login ulang. Desain ini dikenal sebagai token series dan dibahas lebih jauh di chapter Remember-Me pada section Authentication.
Logout yang tuntas membersihkan dua sisi: baris token di database (seperti DELETE pada contoh) dan cookie di browser. Cookie dihapus dengan mengirim setcookie() berumur negatif — teknik yang sama dengan yang kita pakai untuk menghapus cookie biasa:
<?php
// Saat login: kirim token sebagai cookie 30 hari dengan flag lengkap
setcookie('remember_me', $cookieValue, [
'expires' => time() + 60 * 60 * 24 * 30,
'path' => '/',
'secure' => true, // hanya dikirim lewat HTTPS
'httponly' => true, // tidak bisa dibaca JavaScript
'samesite' => 'Lax', // mitigasi CSRF
]);
// Saat logout: hapus cookie di browser dengan umur di masa lalu
setcookie('remember_me', '', ['expires' => 1, 'path' => '/', 'secure' => true, 'httponly' => true]);Blok di atas membutuhkan konteks web sungguhan (header Set-Cookie tidak bekerja di CLI), tetapi makna setiap flag telah kita bahas pada chapter Cookie Options
: secure dan httponly memperkecil peluang token terbaca, sementara samesite ikut meredam CSRF. Dengan rotasi berkala, pencabutan saat logout, dan flag yang lengkap, token remember-me menjadi kompromi yang aman antara kenyamanan dan keamanan.
Ringkasan #
Remember-me menambahkan ingatan jangka panjang di atas session: cookie berumur 30 hari memuat selector:validator, database menyimpan hash validator-nya, dan verifikasi memakai hash_equals() agar kebal timing attack. Token dirotasi setiap pemakaian untuk mendeteksi pencurian, dihapus dari database saat logout, dan tidak pernah berisi password. Aman atau tidaknya seluruh bangunan ini pada akhirnya bergantung pada kebersihan session itu sendiri — topik yang kita rangkum tuntas pada chapter Session Security berikutnya.