Login butuh dua hal: memeriksa password tanpa pernah menyimpan password aslinya, lalu mengingat siapa yang sudah login di request-request berikutnya. Bayangin pelanggan tetap Warung Bu Tini yang pesan lewat website. Di buku pelanggan, Bu Tini gak menulis kata sandinya, cuma "sidik jari"-nya: hasil olahan satu arah yang bisa dicocokkan tapi gak bisa dibalik (itu hash). Setelah cocok, pelanggan dapat kartu meja bernomor. Kartu itu gak berisi apa-apa tentang dia, tapi di kasir ada catatan "meja 7: Budi" (itu session: nomornya di cookie browser, catatannya di server).
🧪 Tebakan cepat:
password_hash("rahasia", PASSWORD_DEFAULT)dijalankan dua kali. Dua hasilnya sama? Dan kalau beda, apakahpassword_verify("rahasia", ...)masih menjawabtrueuntuk keduanya? Tebak dulu, jawabannya ada di bagian "Simpan hash, bukan password".
Simpan hash, bukan password
<?php
$hash = password_hash("rahasia", PASSWORD_DEFAULT);
echo $hash;
// $2y$12$HpOCDD7o12bfvO..lyDNKOY8AmLzK4Z4iJ144gqXbuHQSaq4uepXO
String 60 karakter itu yang disimpan di kolom password tabel users. Bagian-bagiannya:
| Bagian | Contoh | Artinya |
|---|---|---|
$2y$ |
$2y$ |
algoritmanya bcrypt |
| cost | 12$ |
tingkat kerja: tiap naik 1, waktu hash jadi 2 kali lipat |
| salt | HpOCDD7o12bfvO..lyDNKO |
22 karakter acak, beda di tiap panggilan |
| hash | Y8AmLzK4Z4iJ144gqXbuHQSaq4uepXO |
hasil olahan password plus salt |
PASSWORD_DEFAULTdi PHP 8.4 adalah bcrypt dengan cost 12. Sebelum PHP 8.4, cost bawaannya 10, jadi hash lama di database diawali$2y$10$.- Salt-nya acak, jadi
password_hash("rahasia", ...)yang kedua memberi string yang beda, misalnya$2y$12$GlySP.5Ld.aKwpVhKKY.r.FHfgHuo7....password_verify("rahasia", $hash)tetaptrueuntuk keduanya, karena salt dan cost-nya ikut tersimpan di dalam string itu. Itu jawaban tebakannya. PASSWORD_ARGON2IDmemakai Argon2id dan hasilnya lebih panjang, 97 karakter:$argon2id$v=19$m=65536,t=4,p=1$U0tjMFVZWndrRG9aOFBPTA$K8zx....mmemori dalam KB,tjumlah putaran,pjumlah thread.- Karena algoritma bawaannya bisa berganti di versi PHP berikutnya, siapkan kolomnya
VARCHAR(255), bukanCHAR(60).
Kenapa harus hash yang sengaja lambat seperti bcrypt dan Argon2id, bukan md5 atau SHA-256, dibahas di lesson Password Hashing & Storage di track Security.
Login: password_verify
$stmt = $pdo->prepare("SELECT id, password FROM users WHERE email = ?");
$stmt->execute([$_POST["email"] ?? ""]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || !password_verify($_POST["password"] ?? "", $user["password"])) {
exit("Email atau password salah.");
}
password_verify($password, $hash)membaca algoritma, cost, dan salt dari$hash, meng-hash ulang password yang diketik dengan resep yang sama, lalu membandingkan hasilnya."rahasia"cocok,"Rahasia"gak (huruf besar-kecil dibedakan).- Hash gak bisa dibandingkan dengan
===:password_hashyang baru selalu memberi salt baru, jadi string-nya gak pernah sama. - Di halaman login sungguhan, email yang gak terdaftar dan password yang salah dijawab dengan kalimat yang sama. Kalimat yang beda memberi tahu orang luar email mana yang punya akun. Latihan lesson ini sengaja memisahkan
User tidak ditemukandanPassword salahsupaya ketiga cabang logikanya bisa diperiksa.
💡 password_needs_rehash: naikkan hash lama
Hash Budi dibuat waktu server masih PHP 8.3, jadi diawali $2y$10$. Setelah upgrade ke PHP 8.4:
var_dump(password_needs_rehash('$2y$10$PMS5ysymbjbsqqayqfaFp.DvmBxfiuEkY0er1X.6doDVA864tBVCW', PASSWORD_DEFAULT));
// bool(true)
password_needs_rehash menjawab true kalau algoritma atau cost hash-nya beda dari yang diminta sekarang (cost 10 lawan 12, atau Argon2id lawan bcrypt). Satu-satunya saat password asli ada di tangan adalah saat login, jadi di situlah hash-nya diperbarui:
if (password_needs_rehash($user["password"], PASSWORD_DEFAULT)) {
$baru = password_hash($_POST["password"], PASSWORD_DEFAULT);
$pdo->prepare("UPDATE users SET password = ? WHERE id = ?")->execute([$baru, $user["id"]]);
}
Session: session_start dan $_SESSION
HTTP gak mengingat apa pun di antara dua request. Session yang mengingatnya:
<?php
session_start();
$_SESSION["user_id"] = 1;
Set-Cookie: PHPSESSID=058a6624a1fd3d5a2204f8257a9f7609; path=/
session_start()membaca cookiePHPSESSIDdari browser. Kalau belum ada, PHP membuat ID acak 32 karakter dan mengirimnya lewat headerSet-Cookie.$_SESSIONadalah array yang isinya disimpan di server, per ID. Dengan pengaturan bawaan,$_SESSION["user_id"] = 1tersimpan sebagai filesess_058a66...berisiuser_id|i:1;.- Di request berikutnya browser mengirim cookie yang sama,
session_start()membaca file-nya lagi, dan$_SESSION["user_id"]berisi 1. Browser cuma memegang nomornya. session_start()mengirim header, jadi harus jalan sebelum ada output. Kalau output sudah terkirim:Warning: session_start(): Session cannot be started after headers have already been sent.
Setelah login: session_regenerate_id(true)
if (password_verify($_POST["password"], $user["password"])) {
session_regenerate_id(true);
$_SESSION["user_id"] = $user["id"];
}
- Sebelum login, browser sudah punya session ID. Kalau ID itu dipakai terus setelah login, siapa pun yang sudah tahu ID itu (misalnya penyerang yang menyelipkannya ke browser korban) ikut jadi Budi. Serangan ini namanya session fixation.
session_regenerate_id(true)membuat ID baru dan mengirimSet-Cookiebaru. Argumentruemenghapus data di ID lama, jadi ID lama gak berlaku lagi.- Panggil setiap kali tingkat akses berubah: login, dan ganti password. Detail serangannya ada di lesson Session Management di track Security, dan alur login lengkapnya di lesson Session-based Authentication di track Auth.
💡 Cookie session yang aman, dan logout
session_set_cookie_params([
"lifetime" => 0,
"path" => "/",
"secure" => true,
"httponly" => true,
"samesite" => "Lax",
]);
session_start();
Set-Cookie: PHPSESSID=4baf911e6cd84716755e705c61296fa0; path=/; secure; HttpOnly; SameSite=Lax
lifetime0: cookie hilang saat browser ditutup.secure: cuma dikirim lewat HTTPS.httponly: JavaScript gak bisa membacanya, jadi XSS gak bisa mencurinya.samesiteLax: gak ikut terkirim di POST dari situs lain.session_set_cookie_paramsharus dipanggil sebelumsession_start().- Logout: kosongkan
$_SESSION = [];, hancurkan datanya di server dengansession_destroy();, lalu hapus cookie-nya dengansetcookie(session_name(), "", time() - 3600, "/");.
⚠️ Jebakan yang sering ketemu
- Membandingkan hash dengan
===atau==. Hash baru selalu beda karena salt-nya acak. Pakaipassword_verify. - Kolom password terlalu pendek. Argon2id butuh 97 karakter, dan algoritma bawaan bisa berubah. Pakai
VARCHAR(255). - Lupa
session_regenerate_id(true)setelah login. ID yang sama sebelum dan sesudah login membuka session fixation. session_start()setelah ada output. Header sudah terkirim, jadi session gak bisa dimulai. Taruh di paling atas file.
TL;DR: Simpan password_hash($password, PASSWORD_DEFAULT), bukan password-nya: di PHP 8.4 hasilnya bcrypt $2y$12$... 60 karakter, dengan salt acak di dalamnya, jadi dua hash dari password yang sama selalu beda (PASSWORD_ARGON2ID memberi $argon2id$... 97 karakter, siapkan VARCHAR(255)). Cocokkan dengan password_verify($input, $hash), gak pernah dengan ===, dan perbarui hash lama dengan password_needs_rehash saat login. session_start() (sebelum output) memberi browser cookie PHPSESSID dan menyimpan $_SESSION di server. Setelah login berhasil panggil session_regenerate_id(true) supaya ID lama gak bisa dipakai (session fixation), atur cookie-nya dengan session_set_cookie_params (secure, httponly, samesite) sebelum session_start(), dan logout dengan $_SESSION = [], session_destroy(), lalu hapus cookie-nya.