Sepanjang section ini kita membangun satu per satu komponen autentikasi: hash kata sandi, session login, remember-me, reset password, token API, hingga MFA. Komponen yang benar saja belum cukup — form login adalah permukaan yang paling sering dijajaki penyerang, dan kualitas pertahanan ditentukan oleh kebijakan yang mengelilingi komponen-komponen tersebut. Chapter penutup ini merangkai semuanya menjadi satu checklist utuh: kebijakan kata sandi modern, pembatasan laju percobaan login, pesan galat yang tidak membocorkan informasi, pertahanan anti-enumerasi, kebersihan session dan token, hingga pencatatan peristiwa untuk deteksi serangan. Setiap butir disertai contoh kode yang dapat diuji, sehingga checklist ini bukan daftar kosong melainkan pola implementasi yang siap dipakai.
Kebijakan Kata Sandi yang Memihak Panjang #
Aturan lama “minimal 8 karakter, wajib simbol, ganti tiap 90 hari” kini dinilai kontraproduktif. Aturan komposisi memaksa pengguna memilih pola yang bisa diprediksi (P@ssw0rd!), sedangkan rotasi paksa melahirkan deret kata sandi yang makin lemah (rahasia1, rahasia2, rahasia3). Panduan modern NIST SP 800-63B memihak panjang: kata sandi frasa panjang yang mudah diingat (kucing-oren-main-gitar) lebih kuat daripada delapan karakter penuh simbol, karena entropi tumbuh cepat lewat jumlah karakter. Berikut perbandingannya:
| Aturan lama | Rekomendasi modern | Alasan |
|---|---|---|
| Minimal 8 karakter + komposisi wajib | Minimal 12–15 karakter, tanpa aturan komposisi | Kompleksitas memaksa pola terduga |
| Rotasi tiap 90 hari | Rotasi hanya bila ada indikasi kebocoran | Rotasi paksa memperpendek dan melemahkan kata sandi |
| Semua kata sandi diterima | Tolak kata sandi dari daftar umum/kebocoran | Kata sandi populer adalah percobaan pertama penyerang |
Implementasi validasinya ringkas — panjang minimal plus blocklist:
<?php
function validatePassword(string $password): array
{
$errors = [];
if (strlen($password) < 12) {
$errors[] = "panjang minimal 12 karakter";
}
$blocklist = ["password123456", "qwerty123456", "admin123456", "123456789012"];
if (in_array(strtolower($password), $blocklist, true)) {
$errors[] = "kata sandi terlalu umum";
}
return $errors;
}
foreach (["P@ssw0rd!", "password123456", "kucing-oren-main-gitar"] as $password) {
$errors = validatePassword($password);
$status = $errors === [] ? "LULUS" : "DITOLAK (" . implode("; ", $errors) . ")";
printf("%-24s => %s\n", $password, $status);
}Output yang Dihasilkan:
P@ssw0rd! => DITOLAK (panjang minimal 12 karakter)
password123456 => DITOLAK (kata sandi terlalu umum)
kucing-oren-main-gitar => LULUSKetiga baris output menceritakan kisah yang panjang: P@ssw0rd! gagal justru karena terlalu pendek betapapun “kompleks” tampilannya, password123456 gagal karena berada di daftar kata sandi terpopuler dunia, dan frasa panjang sederhana lolos karena jumlah karakternya memberi ruang tebak yang jauh lebih luas. Di produksi, blocklist sebaiknya berupa dataset kata sandi umum yang diperbarui (atau pemeriksaan kebocoran — kita bahas di bagian akhir), bukan daftar empat item buatan tangan seperti contoh.
Membatasi Laju Percobaan Login #
Menebak kata sandi secara online hanya mungkin bila penyerang boleh mencoba berkali-kali tanpa biaya. Rate limiting menjawabnya dengan menjadikan tiap percobaan makin mahal. Kuncinya: hitung kegagalan per kombinasi email dan alamat IP, bukan per email saja — bila hanya per email, penyerang cukup berganti IP untuk lolos, dan sebaliknya penyerang bisa mengunci akun orang lain (kita bahas setelah ini). Penalti yang naik bertahap (progressive throttling) lebih ramah daripada blokir langsung:
<?php
function throttleDelay(int $failedAttempts): int
{
if ($failedAttempts < 5) {
return 0;
}
// penalti eksponensial, dibatasi 60 detik
return min(2 ** ($failedAttempts - 4), 60);
}
foreach ([1, 4, 5, 6, 8, 12, 30] as $gagal) {
printf("Gagal %2d kali => tunda %2d detik sebelum percobaan berikutnya\n", $gagal, throttleDelay($gagal));
}Output yang Dihasilkan:
Gagal 1 kali => tunda 0 detik sebelum percobaan berikutnya
Gagal 4 kali => tunda 0 detik sebelum percobaan berikutnya
Gagal 5 kali => tunda 2 detik sebelum percobaan berikutnya
Gagal 6 kali => tunda 4 detik sebelum percobaan berikutnya
Gagal 8 kali => tunda 16 detik sebelum percobaan berikutnya
Gagal 12 kali => tunda 60 detik sebelum percobaan berikutnya
Gagal 30 kali => tunda 60 detik sebelum percobaan berikutnyaEmpat percobaan pertama bebas penalti agar pengguna yang salah ketik tidak terganggu. Mulai kegagalan kelima, jeda tumbuh eksponensial — 2, 4, 8 detik — hingga mentok 60 detik. Dengan jeda seperti ini, seratus juta tebakan tidak lagi mungkin dalam sehari; menebak enam digit pun jadi proyek bertahun-tahun. Di aplikasi nyata, counter kegagalan disimpan per email+IP di cache (misalnya Redis) dengan masa hidup terbatas, dan jeda dijatuhkan lewat usleep() sebelum respons — atau lebih baik, lewat respons HTTP 429 yang menyertakan header Retry-After.
Pesan Galat Generik dan Kerja yang Seragam #
Dua pesan berbeda — “email tidak terdaftar” dan “kata sandi salah” — adalah petunjuk gratis bagi penyerang: cukup mengisi email untuk memetakan akun mana yang ada. Aturannya satu: gagal login selalu menghasilkan pesan yang identik, apa pun penyebabnya. Ada pula kebocoran halus kedua: waktu respons. Bila email tidak dikenal membuat server langsung menolak sementara email terdaftar menjalani password_verify(), selisih beberapa milidetik itu bisa diukur. Solusinya: verifikasi kata sandi selalu dijalankan, bila perlu terhadap hash pengganti:
<?php
// Hash pengganti: diverifikasi ketika email tidak terdaftar agar
// jumlah kerja server sama untuk semua percobaan.
$hashDummy = password_hash("kata-sandi-pengganti", PASSWORD_DEFAULT);
function verifyLogin(?array $user, string $password, string $hashDummy): string
{
$hash = $user["password_hash"] ?? $hashDummy;
$sah = password_verify($password, $hash);
if ($user === null || !$sah) {
return "Email atau kata sandi salah.";
}
return "LOGIN_BERHASIL";
}
$users = [
"[email protected]" => ["password_hash" => password_hash("rahasia-budi", PASSWORD_DEFAULT)],
];
echo verifyLogin($users["[email protected]"] ?? null, "rahasia-budi", $hashDummy), "\n";
echo verifyLogin($users["[email protected]"] ?? null, "tebakan-salah", $hashDummy), "\n";
echo verifyLogin($users["[email protected]"] ?? null, "apapun-tebakan", $hashDummy), "\n";Output yang Dihasilkan:
LOGIN_BERHASIL
Email atau kata sandi salah.
Email atau kata sandi salah.Baris $hash = $user["password_hash"] ?? $hashDummy; adalah intinya: saat email tidak terdaftar, password_verify() tetap dipanggil dengan hash pengganti, sehingga lintasan eksekusi dan besarnya kerja identik untuk semua kasus. Dua baris terakhir output pun tak terbedakan — penyerang tidak bisa memilah mana email yang terdaftar dari isi maupun durasi responsnya. Untuk perbandingan rahasia lain seperti token dan parameter state, ingat kembali hash_equals() dari chapter API Token
yang berjalan dalam waktu konstan.
Anti-Enumerasi di Seluruh Titik Sentuh #
Form login bukan satu-satunya pintu memetakan akun. Form registrasi, permintaan reset kata sandi, bahkan halaman profil publik — semuanya bisa jadi orakel keberadaan akun bila responsnya berbeda untuk email terdaftar dan tidak:
| Titik sentuh | Respons berbahaya | Respons aman |
|---|---|---|
| Login | “Email tidak terdaftar” | Satu pesan generik untuk semua kegagalan |
| Reset kata sandi | “Email tidak ditemukan di sistem kami” | “Jika email terdaftar, tautan telah dikirim” — selalu |
| Registrasi | — | Tidak bisa disamarkan: pencegahan akun ganda butuh pemberitahuan; imbangi dengan verifikasi email sebelum akun aktif |
| Lupa username | Menampilkan daftar kandidat akun | Tolak lewat mekanisme tertutup, atau nonaktifkan fitur |
Registrasi memang tak bisa sepenuhnya bisu — pengguna sah perlu tahu bila emailnya sudah dipakai — tetapi kerentanannya diredam karena enumerasi lewat registrasi tidak memberi tahu apakah kata sandi tertentu cocok. Titik paling kritis adalah reset kata sandi, karena di sanalah jalur masuk aktif. Pola implementasinya:
<?php
function passwordResetResponse(string $email, array $users, array &$log): string
{
if (isset($users[$email])) {
$log[] = "reset_sent email=$email";
// ... kirim email berisi tautan sekali pakai ...
} else {
$log[] = "reset_ignored email=$email";
}
// Respons ke browser selalu identik, terdaftar atau tidak.
return "Jika email tersebut terdaftar, tautan penggantian telah dikirim.";
}
$users = ["[email protected]" => true];
$log = [];
$responsA = passwordResetResponse("[email protected]", $users, $log);
$responsB = passwordResetResponse("[email protected]", $users, $log);
var_dump($responsA === $responsB);
echo $responsA . "\n";
foreach ($log as $baris) {
echo "[log internal] $baris\n";
}Output yang Dihasilkan:
bool(true)
Jika email tersebut terdaftar, tautan penggantian telah dikirim.
[log internal] reset_sent [email protected]
[log internal] reset_ignored [email protected]Pembandingan bool(true) di output membuktikan kedua respons identik byte demi byte, sementara log internal tetap membedakan reset_sent dan reset_ignored — kejujuran untuk operasional, kebisuan untuk penyerang. Seluruh alur resetnya sendiri (token sekali pakai, hash, masa berlaku) sudah kita bangun pada chapter Password Reset
.
Regenerasi ID Session Setelah Login #
Session fixation terjadi ketika penyerang menanamkan ID session yang diketahuinya ke browser korban — lewat tautan, subdomain, atau header — lalu menunggu korban login dengan ID tersebut. Setelah login sukses, korban “resmi” di dalam session yang ID-nya diketahui penyerang. Penawarnya satu baris wajib: regenerasi ID session pada momen kenaikan hak akses — login, sukses memasukkan kode MFA, maupun reset kata sandi:
<?php
session_id("fixed-sesi-sebelum-login");
session_start();
$_SESSION["user_id"] = 7;
$idLama = session_id();
session_regenerate_id(true);
echo "ID session lama : $idLama\n";
echo "ID berubah : " . var_export(session_id() !== $idLama, true) . "\n";
echo "Data login tetap : user_id=" . $_SESSION["user_id"] . "\n";Output yang Dihasilkan:
ID session lama : fixed-sesi-sebelum-login
ID berubah : true
Data login tetap : user_id=7Argumen true pada session_regenerate_id(true) memerintahkan PHP menghapus berkas session lama, sehingga ID yang tertanam tidak lagi punya wujud di server. Perhatikan output: ID baru berubah menjadi nilai acak yang tidak dicetak (prinsip yang sama dengan token — nilai rahasia tidak ditampilkan), sementara data $_SESSION seperti user_id tetap utuh karena PHP memindahkannya ke ID baru. Mekanisme lengkap penyerangan fixation dan pengaturan cookie session yang aman dibahas pada chapter Session Regeneration
dan chapter Session Security
.
Kebersihan Token Remember-Me #
Cookie remember-me adalah kata sandi berumur panjang yang tertidur di browser, jadi kebersihannya menentukan nasibnya. Standar minimum yang sudah kita bangun di chapter Remember-Me
: token acak random_bytes(32) yang dibagi menjadi pasangan selector:validator, hanya hash validator yang disimpan di database, token dirotasi setiap kali dipakai auto-login, dan seluruh token dicabut saat kata sandi berubah atau pengguna logout dari semua perangkat. Tambahkan perilaku berikut di sisi operasionalnya: cookie hanya dikirim lewat HTTPS (secure), tak terbaca JavaScript (httponly), dan masa hidupnya dibatasi wajar (30 hari, bukan “selamanya”). Kombinasi lengkap flag cookie dibahas pada chapter Cookie Options
.
MFA dan Ancaman Credential Stuffing #
Credential stuffing adalah serangan dengan kredensial yang sah: penyerang memasukkan email dan kata sandi hasil kebocoran situs lain, mengandalkan kebiasaan manusia memakai kata sandi yang sama di mana-mana. Trafiknya tidak bisa dibedakan dari login biasa per permintaan — yang membedakan adalah polanya: ribuan percobaan dari satu IP dengan daftar email beragam. Pertahanan berlapisnya: rate limiting berbasis IP untuk volume, MFA sebagai dinding terakhir yang membuat kata sandi bocor tidak cukup (chapter Multi-Factor Authentication ), dan CAPTCHA atau verifikasi tambahan untuk lalu lintas yang mencurigakan.
Lapisan paling awal adalah mencegah kata sandi bocor dipakai sejak registrasi, dengan memeriksa keanggotaannya di dataset kebocoran publik tanpa mengirim kata sandi itu sendiri. Teknik k-anonymity dari layanan Have I Been Pwned bekerja dengan prefiks SHA-1: klien hanya mengirim lima karakter pertama hash; server membalas semua sufiks yang cocok dengan prefiks tersebut; pencocokan sufiks dilakukan lokal:
<?php
$password = "kucing-oren-main-gitar";
$sha1Penuh = strtoupper(hash("sha1", $password));
$prefiks = substr($sha1Penuh, 0, 5);
echo "SHA-1 lengkap (tidak pernah dikirim): $sha1Penuh\n";
echo "Prefiks yang dikirim ke API : $prefiks\n";Output yang Dihasilkan:
SHA-1 lengkap (tidak pernah dikirim): B8EA97132FB6234205440656104EE0E5DD609557
Prefiks yang dikirim ke API : B8EA9Prefiks lima karakter membuat grup pencocokan berisi ratusan ribu hash — penerima layanan tidak bisa tahu kata sandi mana yang sedang diperiksa. Panggilan jaringannya butuh akses internet sehingga tidak bisa dijalankan sebagai contoh mandiri, tetapi idenya sederhana:
<?php
// Konsep pemakaian API range Have I Been Pwned (butuh jaringan):
$sha1 = strtoupper(hash("sha1", $password));
$prefiks = substr($sha1, 0, 5);
$sufiks = substr($sha1, 5);
$daftar = file_get_contents("https://api.pwnedpasswords.com/range/" . $prefiks);
// Bandingkan $sufiks dengan tiap entri "SUFIKS:jumlah" pada $daftar.
// Kebocoran ditemukan bila sufiks cocok: tolak kata sandi saat registrasi.
Mencatat Peristiwa Autentikasi #
Serangan yang lolos dari semua pintu di atas masih meninggalkan jejak di log — asalkan lognya ada. Peristiwa autentikasi yang wajib dicatat minimal: keberhasilan dan kegagalan login, kegagalan kode MFA, permintaan reset kata sandi, dan pencabutan token. Tiap baris memuat siapa (email atau ID), dari mana (IP), dan kapan (stempel waktu dengan zona waktu):
<?php
function authLog(string $event, string $email, string $ip, string $waktu): string
{
return sprintf("[%s] event=%s email=%s ip=%s", $waktu, $event, $email, $ip);
}
echo authLog("login_failed", "[email protected]", "203.0.113.10", "2026-01-15 08:00:02"), "\n";
echo authLog("login_success", "[email protected]", "203.0.113.10", "2026-01-15 08:00:31"), "\n";
echo authLog("mfa_failed", "[email protected]", "203.0.113.77", "2026-01-15 08:01:12"), "\n";Output yang Dihasilkan:
[2026-01-15 08:00:02] event=login_failed [email protected] ip=203.0.113.10
[2026-01-15 08:00:31] event=login_success [email protected] ip=203.0.113.10
[2026-01-15 08:01:12] event=mfa_failed [email protected] ip=203.0.113.77Baris-baris seperti ini adalah bahan baku deteksi: lima login_failed beruntun dari satu IP dalam semenit adalah percobaan tebak, login_success dari negara berbeda sepuluh menit setelah kegagalan-kegagalan itu layak diperingatkan ke pengguna, dan lonjakan mfa_failed mengindikasikan kata sandi sudah jatuh ke tangan penyerang. Di aplikasi nyata, tulis ke logger terstruktur (error_log() untuk memulai, monolog untuk produksi) dan simpan cukup lama untuk investigasi.
Checklist Keamanan Autentikasi #
Rangkuman seluruh section dalam satu tabel rujukan cepat:
| Area | Mitigasi inti | Rincian di |
|---|---|---|
| Penyimpanan kata sandi | password_hash() + password_verify() | Password Hashing |
| Kebijakan kata sandi | Panjang 12+ dan blocklist, tanpa rotasi paksa | Chapter ini |
| Rate limiting | Throttle progresif per email+IP, bukan lockout permanen | Chapter ini |
| Pesan galat login | Generik + dummy hash agar kerja seragam | Chapter ini |
| Anti-enumerasi | Respons identik di login dan reset | Chapter ini |
| Session | session_regenerate_id(true) saat login, cookie berflag aman | Session Security |
| Remember-me | selector:validator, rotasi, pencabutan saat ganti kata sandi | Remember-Me |
| MFA | TOTP + kode cadangan; WebAuthn untuk level tertinggi | Multi-Factor Authentication |
| Credential stuffing | MFA + rate limit per IP + pemeriksaan kata sandi bocor | Chapter ini |
| Pencatatan | Event auth terstruktur + peringatan pola anomali | Chapter ini |
Referensi #
Dokumentasi resmi untuk pendalaman lebih lanjut:
password_verify— php.netsession_regenerate_id— php.net- OWASP Authentication Cheat Sheet
- NIST SP 800-63B: Digital Identity Guidelines
Ringkasan #
Autentikasi yang aman adalah akumulasi kebiasaan kecil yang benar: kata sandi panjang yang ditolak bila terkenal, percobaan login yang makin mahal, pesan galat yang bisu, session yang lahir ulang, token yang dirotasi, MFA sebagai dinding terakhir, dan log yang jujur untuk deteksi. Checklist di atas menutup siklus “mengenali pengguna” dari serangan aktif. Namun aplikasi tidak berhenti di login — begitu pengguna masuk, aplikasi mulai menerima dan menampilkan data dari luar. Cara berpikir keamanan yang sama perlu diterapkan ke setiap baris kode, dan itulah pembuka Section Web Security: prinsip-prinsip penulisan kode yang aman.