Password Hashing — Auth

Jangan pernah menyimpan password dalam bentuk plain text. Jika database bocor, semua password pengguna terekspos. Solusinya: hashing. Hashing vs Encryption…

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 kolom password_hash mereka 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)}`;
}

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

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

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

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 ===.

Yang akan kamu pelajari