Session ID adalah kunci yang membuka seluruh kehidupan digital pengguna di aplikasi kita: status login, keranjang, preferensi. Siapa pun yang memegang ID tersebut — di cookie Budi — pada dasarnya adalah Budi bagi server. Karena itu keamanan session berpusat pada satu pertanyaan: bagaimana memastikan ID hanya berada di tangan pemiliknya yang sah? Pada chapter ini kita bedah tiga lapis pertahanan: rotasi ID saat login, pembersihan total saat logout, dan atribut cookie yang mempersulit pencurian.
Anatomi Serangan Session Fixation #
Session fixation adalah skenario di mana penyerang berhasil menanamkan session ID buatannya sendiri ke browser korban — lewat link jahat, script XSS, atau akses fisik. Alurnya begini:
- Penyerang mengunjungi aplikasi kita dan memperoleh session ID miliknya sendiri, misalnya lewat cookie
laravel-session. - Penyerang menanamkan ID tersebut ke browser Budi (misalnya lewat URL
https://app.example.com/?session=IDpada aplikasi yang keliru menerima session dari parameter URL). - Budi login menggunakan browser yang sudah tertanam. Session tersebut — yang identitasnya diketahui penyerang — kini membawa status login Budi.
- Penyerang memakai ID yang sama dan langsung diterima sebagai Budi.
Titik lemahnya ada di langkah 3: jika session ID tidak berubah saat login, ID lama yang bocor tetap sah setelah autentikasi. Pertahanannya sederhana namun tegas: ganti ID pada momen login, sehingga pengetahuan penyerang tentang ID lama menjadi tidak berharga.
Laravel Mengganti ID Otomatis Saat Login #
Di zaman Laravel 10 ke bawah, programmer diminta memanggil $request->session()->regenerate() secara manual di controller login. Sejak Laravel 11, rotasi tersebut terjadi otomatis di dalam proses login. Bukti ada di source SessionGuard — class inti autentikasi berbasis session — pada file vendor/laravel/framework/src/Illuminate/Auth/SessionGuard.php:
public function login(AuthenticatableContract $user, $remember = false)
{
$this->updateSession($user->getAuthIdentifier());
// ... menulis password_hash_web, remember me, event ...
}
protected function updateSession($id)
{
$this->session->put($this->getName(), $id);
$this->session->regenerate(true);
}Setiap Auth::attempt() yang berhasil berujung pada regenerate(true): session ID diganti dan baris lama di storage dihapus. Kita buktikan dengan feature test yang memainkan skenario fixation secara utuh — penyerang menanamkan ID berformat valid (40 karakter alfanumerik) ke browser korban:
public function test_login_mengganti_session_id_otomatis(): void
{
config(['session.driver' => 'database']);
User::factory()->create([
'name' => 'Budi',
'email' => '[email protected]',
'password' => Hash::make('rahasia-budi-123'),
]);
// Penyerang menanamkan session ID buatan di browser korban
$idTanam = 'eY3bK9mQ2xV8nR5wJ7hF1dL4sP6tZ0aC3gU9oI2k';
$this->withCookie('laravel-session', $idTanam);
$this->get('/sesi/simpan');
fwrite(STDERR, 'fixation: id_tanam_muncul_di_tabel=' . var_export(
in_array($idTanam, DB::table('sessions')->pluck('id')->all()), true) . "\n");
// Korban login dari browser yang sudah tertanam
$this->post('/sesi/login', [
'email' => '[email protected]',
'password' => 'rahasia-budi-123',
])->assertSee('login-sukses');
$idSetelah = DB::table('sessions')->pluck('id')->all();
fwrite(STDERR, 'fixation: id_tanam_masih_ada=' . var_export(in_array($idTanam, $idSetelah), true)
. ' jumlah_baris=' . count($idSetelah) . " (ID lama dihapus, ID baru dibuat)\n");
$row = DB::table('sessions')->first();
$payload = json_decode((string) base64_decode($row->payload), true);
$kunciLogin = array_filter(array_keys($payload), fn ($k) => str_starts_with($k, 'login_web_'));
fwrite(STDERR, 'login: user_id_terisi=' . $row->user_id
. ' kunci=' . implode(',', $kunciLogin) . '|password_hash_web'
. ' nama=' . var_export($payload['nama'] ?? null, true)
. " (data dipertahankan, ID diganti)\n");
}Output yang Dihasilkan:
fixation: id_tanam_muncul_di_tabel=true
fixation: id_tanam_masih_ada=false jumlah_baris=1 (ID lama dihapus, ID baru dibuat)
login: user_id_terisi=1 kunci=login_web_59ba36addc2b2f9401580f014c7f58ea4e30989d|password_hash_web nama='Budi' (data dipertahankan, ID diganti)Baris pertama menegaskan skenario penyerang berhasil: ID tanaman benar-benar hidup di tabel sessions. Baris kedua adalah balasan Laravel: setelah login, ID tanaman sudah dihapus dan diganti ID baru yang tidak diketahui penyerang. Baris ketiga memperlihatkan isi session baru — user_id terisi, kunci status login (login_web_* dan password_hash_web) tertulis, dan data lama seperti nama ikut dipindahkan. Serangan fixation gagal di langkah terakhirnya.
invalidate() Saat Logout #
Logout butuh perlakuan yang lebih keras dari sekadar menghapus status login: seluruh data session dibuang dan ID diganti lagi, agar tidak ada sisa data yang menempel. Kombinasi standarnya:
public function logout(Request $request)
{
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect('/login');
}Feature test menutup cerita di atas dengan memanggil route logout dari browser korban:
// Browser korban kini memegang cookie hasil login, bukan tanaman lama
$idBaru = DB::table('sessions')->value('id');
$this->defaultCookies = [];
$this->withCookie('laravel-session', $idBaru);
$this->post('/sesi/logout')->assertSee('logout-sukses');
$rowLogout = DB::table('sessions')->first();
$payloadLogout = json_decode((string) base64_decode($rowLogout->payload), true);
unset($payloadLogout['_token'], $payloadLogout['_previous'], $payloadLogout['_flash']);
$kunciLogin = count(array_filter(array_keys($payloadLogout),
fn ($k) => str_starts_with($k, 'login_web_')));
fwrite(STDERR, 'logout: user_id=' . var_export($rowLogout->user_id, true)
. ' nama=' . var_export($payloadLogout['nama'] ?? null, true)
. ' kunci_login_web=' . $kunciLogin
. ' isi_payload_kosong=' . var_export($payloadLogout === [], true) . "\n");Output yang Dihasilkan:
logout: user_id=NULL nama=NULL kunci_login_web=0 isi_payload_kosong=trueSetelah invalidate(), baris session menunjukkan user_id kosong, kunci login_web_* lenyap, dan payload tinggal kunci-kunci internal framework — akun benar-benar tertutup, bukan sekadar “tanda logout” ditempel.
Atribut Cookie yang Melindungi ID #
Cookie session dikirim bersama setiap request, jadi atributnya menentukan seberapa aman ID berkeliling. Feature test membaca cookie dari response asli lalu mencetak atributnya:
config(['session.driver' => 'database']);
$response = $this->get('/sesi/simpan')->assertOk();
foreach ($response->headers->getCookies() as $cookie) {
fwrite(STDERR, 'cookie: nama=' . $cookie->getName()
. ' nilai=<nilai-acak>'
. ' path=' . $cookie->getPath()
. ' secure=' . var_export($cookie->isSecure(), true)
. ' httpOnly=' . var_export($cookie->isHttpOnly(), true)
. ' sameSite=' . ($cookie->getSameSite() ?: '(tidak diset)') . "\n");
}Output yang Dihasilkan:
cookie: nama=XSRF-TOKEN nilai=<nilai-acak> path=/ secure=false httpOnly=false sameSite=lax
cookie: nama=laravel-session nilai=<nilai-acak> path=/ secure=false httpOnly=true sameSite=laxOutput tersebut diambil dari lingkungan lokal ber-HTTP. Variasi keduanya bisa kita lihat dengan mengaktifkan session.secure lalu mengulang request — lupakan instance session terlebih dahulu agar konfigurasi baru benar-benar terbaca:
// Variasi SESSION_SECURE_COOKIE=true (produksi di balik HTTPS)
$this->app->forgetInstance('session');
$this->app->forgetInstance('session.store');
$this->app->forgetInstance(StartSession::class);
config(['session.secure' => true]);
foreach ($this->get('/sesi/simpan')->assertOk()->headers->getCookies() as $cookie) {
if ($cookie->getName() === 'laravel-session') {
fwrite(STDERR, 'cookie_https: nama=' . $cookie->getName()
. ' secure=' . var_export($cookie->isSecure(), true) . "\n");
}
}Output yang Dihasilkan:
cookie_https: nama=laravel-session secure=trueTiga atribut pada laravel-session layak digarisbawahi. httpOnly=true melarang JavaScript membaca cookie — script jahat hasil XSS tidak bisa mencuri ID lewat document.cookie. sameSite=lax membatasi pengiriman cookie pada request lintas situs, mempersulit CSRF. Sedangkan secure=false pada output pertama karena lingkungan pengujian kita berjalan di HTTP lokal — di production ber-HTTPS, setel SESSION_SECURE_COOKIE=true agar cookie hanya dikirim melalui koneksi terenkripsi, seperti bukti secure=true di atas. Perhatikan juga XSRF-TOKEN sengaja httpOnly=false karena cookie tersebut justru perlu dibaca JavaScript agar header X-XSRF-TOKEN bisa disertakan pada request AJAX.
Konfigurasi ketiganya ada di config/session.php:
Opsi .env | Atribut cookie | Efek |
|---|---|---|
SESSION_SECURE_COOKIE=true | Secure | Cookie hanya dikirim via HTTPS |
SESSION_HTTP_ONLY (default true) | HttpOnly | JavaScript dilarang membaca cookie |
SESSION_SAME_SITE=lax | SameSite | Membatasi pengiriman lintas situs |
Masa Hidup: SESSION_LIFETIME #
Session tidak hidup selamanya. Variabel SESSION_LIFETIME=120 di .env memberi batas 120 menit — tetapi bukan dihitung sejak login, melainkan sejak aktivitas terakhir: setiap request memperbarui kolom last_activity, dan session yang diam melewati batas dianggap kedaluwarsa. Feature test berikut memundurkan last_activity 121 menit ke belakang, lalu mengirim request baru dari browser yang sama:
$this->get('/sesi/simpan');
$idSesi = DB::table('sessions')->value('id');
$this->get('/sesi/baca')->assertSee('nama=Budi');
// Mundurkan last_activity melewati batas SESSION_LIFETIME (120 menit)
DB::table('sessions')->update(['last_activity' => time() - (121 * 60)]);
// Request baru dari browser yang sama (cookie session tetap dikirim)
$this->mulaiRequestBaru($idSesi);
$r = $this->get('/sesi/baca');
fwrite(STDERR, 'setelah_expired: ' . $r->getContent() . "\n");Output yang Dihasilkan:
setelah_expired: nama= (session ditolak, data dimulai baru)Cookie tetap dikirim, tetapi server menolaknya karena last_activity terlalu tua — request dilayani dengan session baru yang kosong. Berkat SESSION_EXPIRE_ON_CLOSE=true kita juga bisa meminta session mati begitu browser ditutup. Perlu dicatat: lifetime ini berbasis aktivitas (idle timeout), bukan batas mutlak sejak login — untuk kebijakan “maksimal 8 jam sejak login apa pun yang terjadi”, kita perlu membandingkan sendiri timestamp login pertama yang kita simpan di session.
Dengan rotasi ID, pembersihan logout, atribut cookie, dan masa hidup yang terkelola, session kita sudah berdiri di fondasi yang aman. Sisi lain dari penyimpanan sementara di Laravel — yang tidak berbasis pengguna, melainkan berbasis hasil komputasi — adalah cache, topik pembuka bab berikutnya di section ini. Rincian konfigurasi selengkapnya ada di dokumentasi Session .