Kata sandi adalah kunci paling sensitif di aplikasi kita, dan suatu hari database bisa bocor — lewat SQL injection, backup yang tercecer, atau server yang salah konfigurasi. Pertanyaannya bukan “apakah database bisa bocor” melainkan “apa yang didapat penyerang saat database itu ada di tangannya”. Bila kolom kata sandi berisi teks polos, semua akun langsung jatuh; bila berisi hash yang benar, penyerang harus menebak satu per satu kata sandi dengan biaya komputasi mahal. Pada chapter ini kita memakai fungsi bawaan PHP — password_hash(), password_verify(), dan password_needs_rehash() — untuk menyimpan kredensial dengan cara yang tahan terhadap kebocoran.

Hashing Bukan Enkripsi #

Hashing dan enkripsi sering tertukar padahal berbeda fundamental. Enkripsi bersifat dua arah: data diacak dengan kunci dan bisa dikembalikan ke asalnya oleh pihak yang memegang kunci. Hashing bersifat satu arah: fungsi hash memetakan input berapa pun panjangnya menjadi deretan bit tetap, dan tidak ada proses “unhash” untuk memulihkan input dari hasilnya. Untuk kata sandi, kita justru butuh sifat satu arah ini — aplikasi kita sendiri tidak perlu (dan tidak seharusnya bisa) membaca kata sandi pengguna kembali.

Lalu bagaimana cara memverifikasi login? Saat pengguna login, aplikasi meng-hash teks yang diketik pengguna dengan parameter yang sama, lalu membandingkan hasilnya dengan hash tersimpan. Bila keduanya cocok, teks yang diketik hampir pasti kata sandi yang sama. Seluruh mekanisme ini dibungkus PHP menjadi dua fungsi — password_hash() untuk membuat, password_verify() untuk membandingkan — sehingga kita tidak pernah menulis logika perbandingan sendiri.

password_hash dan Struktur Hashnya #

Fungsi password_hash() menerima kata sandi mentah dan konstanta algoritma, lalu mengembalikan string hash siap simpan. Hash bcrypt selalu berformat $2y$<cost>$<salt+hash>: penanda algoritma, faktor biaya, lalu salt acak yang menyatu dengan hasil hash. Karena salt dihasilkan acak setiap kali, dua panggilan dengan kata sandi sama menghasilkan hash berbeda — maka pada contoh ini kita mencetak fakta struktur hash, bukan hash mentahnya yang berubah setiap eksekusi:

php
<?php

$satu = password_hash('rahasia-kelas', PASSWORD_BCRYPT);
$dua  = password_hash('rahasia-kelas', PASSWORD_BCRYPT);

echo "panjang hash       : " . strlen($satu) . " karakter\n";
echo "prefiks algoritma  : " . substr($satu, 0, 4) . "\n";
echo "hash kedua identik : " . var_export($satu === $dua, true) . "\n";

$bagian = explode('$', $satu);
echo "penanda algoritma  : {$bagian[1]}\n";
echo "cost               : {$bagian[2]}\n";
echo "panjang salt+hash  : " . strlen($bagian[3]) . " karakter\n";

Output yang Dihasilkan:

text
panjang hash       : 60 karakter
prefiks algoritma  : $2y$
hash kedua identik : false
penanda algoritma  : 2y
cost               : 12
panjang salt+hash  : 53 karakter

Mari kita bedah hasilnya:

  1. Panjang 60 karakter: hash bcrypt selalu tepat 60 karakter — fakta yang berguna saat mendesain kolom database (VARCHAR(60) atau lebih longgar VARCHAR(255) untuk antisipasi algoritma lain).
  2. Prefiks $2y$: penanda algoritma bcrypt versi yang benar; format ini juga yang dibaca password_verify() untuk tahu cara memverifikasi.
  3. hash kedua identik : false: inilah bukti kerja salt acak — kata sandi yang sama menghasilkan hash berbeda setiap kali di-hash, sehingga penyerang tidak bisa mencocokkan hash database dengan kamus hash yang disiapkan di muka.
  4. $bagian = explode('$', $satu): memecah hash pada tanda $ menghasilkan ['', '2y', '<cost>', '<salt+hash>'] — cost dan salt ternyata melekat di dalam string hash, bukan kolom terpisah.

Memverifikasi dengan password_verify #

Verifikasi tidak pernah membandingkan string secara manual — password_verify() menerima kata sandi mentah dan hash tersimpan, lalu mengulangi proses hash dengan parameter yang terenkode di dalam hash tersebut sebelum membandingkan hasilnya dengan aman:

php
<?php

$storedHash = password_hash('rahasia-kelas', PASSWORD_BCRYPT);

var_dump(password_verify('rahasia-kelas', $storedHash));
var_dump(password_verify('tebakan-salah', $storedHash));
var_dump(password_verify('RAHASIA-KELAS', $storedHash));

Output yang Dihasilkan:

text
bool(true)
bool(false)
bool(false)

Dalam aplikasi nyata, $storedHash berasal dari kolom password_hash milik pengguna yang login — variabel di sini hanya mensimulasikannya agar contoh berdiri sendiri. Tiga baris output menunjukkan perilaku penting: kata sandi yang tepat menghasilkan true, tebakan salah menghasilkan false, dan variasi huruf kapital juga false — perbandingan kata sandi bersifat case-sensitive persis seperti byte-nya. Fungsi ini juga melindungi kita dari kesalahan umum memakai == yang di PHP bisa menganggap string tertentu “sama” padahal berbeda.

Argon2id #

Bcrypt dirancang di era ketika serangan dijalankan di CPU. Keluarga Argon2 memenangkan kompetisi Password Hashing dengan keunggulan lain: Argon2id adalah algoritma memory-hard yang juga memakai banyak memori, sehingga GPU dan perangkat khusus penambang (yang punya memori kecil per core) kehilangan keungguan massalnya. PHP menyediakannya lewat konstanta PASSWORD_ARGON2ID, dan fungsi password_algos() melaporkan algoritma yang tersedia di build berjalan:

php
<?php

if (in_array('argon2id', password_algos(), true)) {
    $hash = password_hash('rahasia-kelas', PASSWORD_ARGON2ID);
    echo "argon2id tersedia di build ini\n";
    echo "panjang hash : " . strlen($hash) . " karakter\n";
    echo "prefiks      : " . substr($hash, 0, 9) . "\n";
} else {
    echo "argon2id tidak tersedia, gunakan PASSWORD_BCRYPT\n";
}

Output yang Dihasilkan:

text
argon2id tersedia di build ini
panjang hash : 97 karakter
prefiks      : $argon2id

Hash Argon2id lebih panjang (97 karakter) dan prefiksnya membawa parameter lengkap: versi, ukuran memori, jumlah iterasi, dan derajat paralelisme — semuanya terenkode dalam string yang sama, persis seperti cost pada bcrypt. Bila build PHP Anda tidak memiliki ekstensi argon2, PASSWORD_BCRYPT tetap merupakan pilihan yang aman dan dijaga kompatibilitasnya; Argon2id adalah peningkatan, bukan syarat. Fungsi password_verify() kembali menangani perbedaan format ini secara otomatis karena algoritma terbaca dari prefiks hash.

Cost Factor dan password_needs_rehash #

Keamanan hash kata sandi bergantung pada lambatnya algoritma secara sengaja: setiap penambahan satu angka cost menggandakan kerja yang dibutuhkan per percobaan. Cost yang pas berubah seiring waktu — hardware makin cepat, jadi cost harus ikut naik. Masalahnya, hash yang sudah tersimpan di database tidak berubah sendiri. Fungsi password_needs_rehash() memeriksa apakah hash lama masih memenuhi parameter saat ini, biasanya dilakukan tepat setelah login berhasil ketika kata sandi mentah sedang ada di tangan kita:

php
<?php

$password = 'rahasia-kelas';

$hashLama = password_hash($password, PASSWORD_BCRYPT, ['cost' => 10]);
$hashBaru = password_hash($password, PASSWORD_BCRYPT, ['cost' => 12]);

var_dump(password_needs_rehash($hashLama, PASSWORD_BCRYPT, ['cost' => 12]));
var_dump(password_needs_rehash($hashBaru, PASSWORD_BCRYPT, ['cost' => 12]));
var_dump(password_verify($password, $hashBaru));

Output yang Dihasilkan:

text
bool(true)
bool(false)
bool(true)

Hash dengan cost 10 pada contoh ini memerankan hash lawas dari masa saat standar itu masih cukup — misalnya akun lama yang dibuat sebelum PHP 8.4. Pemeriksaan password_needs_rehash($hashLama, ...) mengembalikan true karena parameternya tidak lagi cocok, sementara hash cost 12 mengembalikan false. Alur lengkapnya di handler login: verifikasi dulu dengan password_verify(); bila berhasil dan password_needs_rehash() bernilai true, buat hash baru dengan password_hash() dan perbarui baris database — pengguna tidak merasakan apa-apa. Kata sandi tidak pernah bisa “di-upgrade” tanpa pengguna login, jadi migrasi berlangsung bertahap mengikuti kunjungan pengguna.

Mengapa md5 dan sha1 Dilarang untuk Kata Sandi #

Fungsi md5() dan sha1() tetap sah untuk keperluan non-keamanan seperti checksum integritas berkas, tetapi menggunakannya untuk kata sandi adalah kesalahan fatal. Keduanya adalah hash cepat — justru karena cepat, penyerang dengan GPU modern bisa menguji miliaran tebakan per detik. Ditambah tanpa salt, input yang sama selalu menghasilkan output yang sama, sehingga kamus pra-hitung (rainbow table) tinggal mencocokkan:

php
<?php

$password = 'rahasia-kelas';

echo "md5  : " . md5($password) . "\n";
echo "md5  : " . md5($password) . " (selalu identik untuk input sama)\n";
echo "sha1 : " . sha1($password) . "\n";

Output yang Dihasilkan:

text
md5  : f43c1662d35a5776f81eb3a13a4cb9ea
md5  : f43c1662d35a5776f81eb3a13a4cb9ea (selalu identik untuk input sama)
sha1 : 8537ae732d426c78cde569ed1ff4536bac318a5b

Output memperlihatkan inti masalahnya: md5('rahasia-kelas') menghasilkan deretan hex yang sama persis di komputer mana pun, selamanya. Penyerang cukup menyiapkan tabel md5 untuk jutaan kata sandi umum sekali, lalu membobol berbagai database dengan tabel yang sama. Bandingkan dengan bcrypt pada contoh sebelumnya: salt acak membuat setiap hash unik, dan cost membuat setiap tebakan mahal.

Salt Otomatis dan Verifikasi yang Aman #

Kita telah melihat salt bekerja: prefiks hash berbeda pada setiap panggilan untuk kata sandi yang sama. Yang perlu dipahami adalah bahwa salt tidak dirahasiakan — salt tercetak jelas di dalam string hash — dan tidak perlu dirahasiakan. Tugas salt hanya memastikan dua kata sandi identik menghasilkan hash berbeda, sehingga kamus pra-hitung tidak bisa dipakai ulang dan dua pengguna dengan kata sandi sama tidak saling terungkap. Karena PHP membuat salt otomatis dan menempelkannya pada hash, tidak ada alasan menulis logika salt sendiri.

Prinsip terakhir: pesan kesalahan login harus umum. Mari kita rangkai verifikasi menjadi handler login kecil:

php
<?php

function verifyLogin(string $password, string $storedHash): array
{
    if (!password_verify($password, $storedHash)) {
        return ['status' => 401, 'error' => 'invalid_credentials'];
    }

    return ['status' => 200, 'error' => 'ok'];
}

$storedHash = password_hash('rahasia-kelas', PASSWORD_BCRYPT);

$login1 = verifyLogin('rahasia-kelas', $storedHash);
$login2 = verifyLogin('sandi yang salah', $storedHash);

echo "login benar : HTTP {$login1['status']} {$login1['error']}\n";
echo "login salah : HTTP {$login2['status']} {$login2['error']}\n";

Output yang Dihasilkan:

text
login benar : HTTP 200 ok
login salah : HTTP 401 invalid_credentials

Handler verifyLogin() mengembalikan invalid_credentials — bukan “email tidak terdaftar” atau “kata sandi salah” — karena membedakan keduanya memberi tahu penyerang alamat email mana yang benar-benar punya akun (user enumeration). Satu catatan perbandingan terakhir: password_verify() sudah aman dari timing attack, tetapi saat membandingkan nilai yang BUKAN hash kata sandi — misalnya token API mentah — gunakan hash_equals() yang konstan waktunya; kita akan memakainya lagi pada chapter API Token Authentication .

Referensi #

Ringkasan #

Kata sandi disimpan sebagai hash satu arah yang lambat dan bergaram: password_hash() membuatnya dengan algoritma bcrypt atau Argon2id, password_verify() membandingkannya tanpa pernah mengungkap kata sandi, dan password_needs_rehash() menjaga hash lama tetap mengikuti standar cost yang naik. md5 dan sha1 terlarang untuk keperluan ini karena cepat dan tanpa salt — dua sifat yang justru menguntungkan penyerang. Salt dibuat otomatis dan terenkode dalam hash, jadi fokus kita murni pada pemilihan algoritma dan cost. Dengan fondasi penyimpanan yang aman ini, kita siap membangun alur pengguna yang pertama: registrasi akun baru pada chapter berikutnya.