Server gak pernah menyimpan password. Yang disimpan adalah hash-nya: hasil olahan satu arah yang bisa dicocokkan, tapi gak bisa dikembalikan jadi password. Bayangin kamu menyimpan jus, bukan buahnya. Dari buah gampang jadi jus, dari jus mustahil balik jadi buah. Untuk mengecek buah yang dibawa tamu, kamu blender dengan bumbu yang sama, lalu cicipi: rasanya sama berarti buahnya sama.
🧪 Tebakan cepat: di Portal Sekolah, Siti dan Pak Andi kebetulan memakai password yang sama,
rahasia123. Isi kolompassword_hashmereka sama atau beda? Tebak dulu, jawabannya ada di bagian "Daftar: bikin salt, lalu hash".
Hash, bukan encrypt
Encrypt itu dua arah: siapa pun yang memegang kuncinya bisa membuka isinya lagi. Kalau database dan kuncinya bocor bersamaan, semua password terbuka. Hash itu satu arah: gak ada kunci yang bisa membalikkannya. Server gak perlu tahu password aslimu. Cukup menghitung ulang hash dari password yang kamu ketik, lalu membandingkan.
Kenapa hash cepat seperti SHA-256 gak boleh dipakai untuk password, dan apa itu rainbow table, dibahas di lesson Password Hashing & Storage di track Security. Di sini kita membangun dua momennya: daftar dan login.
Daftar: bikin salt, lalu hash
Salt adalah byte acak yang dibuat baru untuk tiap user dan ikut dicampur ke hash. Browser punya fungsi hash password bawaan di Web Crypto, yaitu PBKDF2: dia menjalankan HMAC-SHA-256 berulang kali, sebanyak iterasi yang kamu minta, supaya tiap tebakan mahal.
const enc = new TextEncoder();
const toB64 = (bytes) => btoa(String.fromCharCode(...bytes));
async function pbkdf2(password, salt, iterations) {
const key = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]);
const bits = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations }, key, 256);
return new Uint8Array(bits);
}
async function hashPassword(password, iterations = 600000) {
const salt = crypto.getRandomValues(new Uint8Array(16)); // 16 byte acak, baru untuk tiap user
const hash = await pbkdf2(password, salt, iterations);
return `pbkdf2_sha256${iterations}${toB64(salt)}${toB64(hash)}`;
}
crypto.getRandomValuesmembuat salt yang gak bisa ditebak. Jangan pakaiMath.random.deriveBits(..., 256)menghasilkan hash 32 byte.- Yang disimpan satu string berisi empat bagian, dipisah
$: nama algoritma, jumlah iterasi, salt, dan hash. Waktu login, server butuh ketiganya untuk menghitung ulang.
Ini isi tabel users Portal Sekolah di Lab, dengan kebijakan lama 100.000 iterasi:
| User | password_hash |
|---|---|
| Siti | pbkdf2_sha256$100000$ej8ZxOK4DVah9MOeiyfQZQ==$CRBFzAx1PRkFysr8uQEkyf/zTGTdXgrJLsPqzbKJlVA= |
| Pak Andi | pbkdf2_sha256$100000$yB5dCpfyNGsOnafBXziy5A==$PJ8ycPDSyTErMtUsfaIa4ZaxWcrHuUiQeclWLXa69gU= |
Password keduanya rahasia123, tapi salt-nya beda, jadi hash-nya beda. Itu jawaban tebakannya: beda. Orang yang membaca tabel ini gak bisa tahu bahwa dua orang itu memakai password yang sama.
Login: hitung ulang, lalu bandingkan
const fromB64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i]; // selalu memeriksa semua byte
return diff === 0;
}
async function verifyPassword(password, stored) {
const [algo, iterations, salt, hash] = stored.split("quot;);
if (algo !== "pbkdf2_sha256") return false;
const again = await pbkdf2(password, fromB64(salt), Number(iterations));
return timingSafeEqual(again, fromB64(hash));
}
const siti = { password_hash: "pbkdf2_sha256$100000$ej8ZxOK4DVah9MOeiyfQZQ==$CRBFzAx1PRkFysr8uQEkyf/zTGTdXgrJLsPqzbKJlVA=" };
console.log(await verifyPassword("rahasia123", siti.password_hash)); // true
console.log(await verifyPassword("rahasia124", siti.password_hash)); // false
- Salt dan iterasinya diambil dari string yang tersimpan, jadi hasilnya sama persis kalau password-nya sama.
timingSafeEqualselalu membandingkan 32 byte penuh, sehingga waktunya gak bergantung pada letak byte pertama yang beda.- Email yang gak terdaftar dijawab dengan pesan yang sama, "Email atau password salah", dan server tetap menjalankan satu hash palsu. Kalau gak, jawaban untuk email tak terdaftar terasa jauh lebih cepat, dan orang bisa menebak email mana yang terdaftar. Login BelajarWeb.dev sendiri melakukan ini:
Hash::checktetap jalan dengan hash bcrypt palsu.
Berapa iterasi, dan cara menaikkannya
OWASP menyarankan 600.000 iterasi untuk PBKDF2-HMAC-SHA256. Satu login jadi butuh puluhan sampai ratusan milidetik. Untuk satu orang gak terasa, tapi penyerang yang mau mencoba miliaran tebakan ikut melambat sebanyak itu. Lab mengukur waktunya di browser-mu.
Kalau kebijakan naik dari 100.000 ke 600.000, hash lama gak bisa diubah langsung, karena password aslinya gak ada di server. Caranya: rehash saat login. Begitu login berhasil, server sedang memegang password yang benar, jadi dia menghitung hash baru (salt baru, 600.000 iterasi) dan menimpa baris lama.
Di Laravel: bcrypt
Di server PHP kamu gak menulis PBKDF2 sendiri. Laravel memakai bcrypt secara default (atau argon2id, yang juga butuh banyak memori per tebakan):
$hash = Hash::make('rahasia123');
// $2y$12$I1z.vF51e4DRDE5vX0rxhu3yKqdkk35vWuzX940DskCnkADxiRaF.
Hash::check('rahasia123', $hash); // true
Hash::check('rahasia124', $hash); // false
Hash::needsRehash($hash); // true kalau cost di config sudah naik
$2y$artinya bcrypt,12adalah cost (2 pangkat 12 = 4.096 putaran), 22 karakter berikutnya salt, 31 karakter terakhir hash-nya. Totalnya 60 karakter.bcrypt('rahasia123')dua kali menghasilkan dua string berbeda, karena salt-nya acak. Jadivar_dump($hash1 === $hash2)mencetakbool(false). Bandingkan denganHash::check, jangan dengan===.Auth::attemptdi Laravel sudah melakukan rehash saat login secara otomatis (rehash_on_login). BelajarWeb.dev memakai bcrypt dengan cost 12 (BCRYPT_ROUNDS=12).
Kenapa Lab memakai PBKDF2? Karena itu yang tersedia di Web Crypto browser. Idenya sama dengan bcrypt dan argon2id: salt per user, dan lambat dengan sengaja.
⚠️ Jebakan yang sering ketemu
- Membandingkan dua hash dengan
===. Hash baru selalu punya salt baru, jadi hasilnya selalu beda. Pakai fungsi verify (Hash::check,password_verify). - Salt yang sama untuk semua user, atau tanpa salt. Password sama langsung kelihatan sama di tabel.
- Pesan atau waktu yang beda untuk email tak terdaftar. Jawab dengan pesan yang sama dan tetap jalankan hash palsu.
- Menurunkan cost biar cepat. Cost rendah cuma boleh di test (
BCRYPT_ROUNDS=4diphpunit.xml), bukan di production.
TL;DR: Simpan hash, bukan password, karena hash satu arah sedangkan encrypt bisa dibuka dengan kunci. Waktu daftar: salt acak per user, hash yang lambat (PBKDF2 600.000 iterasi, bcrypt, atau argon2id), simpan algoritma, parameter, salt, dan hash dalam satu string. Waktu login: hitung ulang dengan salt dan parameter yang tersimpan, bandingkan semua byte, jawab email tak terdaftar dengan pesan dan waktu yang sama. Naikkan cost lewat rehash saat login. Di Laravel: Hash::make, Hash::check, Hash::needsRehash, dan jangan pernah ===.