Hashing Password: Kenapa Tidak Cukup Disimpan Biasa
Setiap aplikasi yang punya sistem login pasti menyimpan password pengguna di suatu tempat. Pertanyaannya: di tempat itu, password tersimpan dalam bentuk apa? Jawaban yang benar hanya satu — tersimpan sebagai hash (kode acak hasil proses satu arah), bukan sebagai teks asli (plaintext). Artikel ini membahas kenapa menyimpan password secara apa adanya sangat berbahaya, apa bedanya hashing dengan encryption, mengapa algoritma hash lama tidak cukup, dan bagaimana mengimplementasikan penyimpanan password yang aman dengan BCrypt di Spring Security.
Masalah: Bahaya Menyimpan Password Plaintext
Plaintext artinya password disimpan persis seperti yang diketik pengguna, misalnya "HendroGanteng123" tersimpan apa adanya di kolom database. Ini adalah dosa terbesar dalam pengembangan backend.
Bayangkan alur serangan yang paling sederhana:
- Peretas menemukan celah SQL injection atau miskonfigurasi server.
- Database pengguna bocor keluar — entah ke publik, dijual, atau diposting di forum gelap.
- Semua password kini terbaca langsung. Tidak perlu dipecahkan, tidak perlu alat canggih.
Kebocoran seperti ini bukan fiksi. Peristiwa besar yang sering dijadikan pelajaran: pada 2012, LinkedIn kehilangan jutaan kredensial yang ternyata disimpan tanpa hash yang layak; tahun 2015 lalu data jutaan pengguna layanan dewasa di bocorkan beserta password yang tidak di-hash dengan baik. Dalam setiap kasus itu, peretas langsung mendapat password asli, dan karena kebanyakan orang memakai password yang sama di banyak situs (password reuse), satu database bocor bisa membuka pintu ke akun email, media sosial, bahkan rekening bank korban.
Ada dua istilah penting yang memperparah hal ini:
Credential stuffing— serangan otomatis yang mencoba kombinasi email/password yang bocor dari satu situs ke ribuan situs lain. Karena orang suka memakai password yang sama, banyak yang berhasil.Data breach— kebocoran data massal. Kerusakannya berlipat ganda jika data yang bocor berupa password plaintext.
Prinsip keamanan yang tidak bisa ditawar: password adalah satu-satunya "kunci" yang dimiliki pengguna untuk akunnya. Database Anda boleh bocor, tapi yang bocor itu tidak boleh membuat peretas bisa masuk ke akun pengguna. Karena itu password harus disimpan dalam bentuk yang mustahil dibalik.
Hash vs Enkripsi
Banyak orang (termasuk beberapa developer) mencampuradukkan hashing dan encryption. Padahal perbedaannya mendasar dan menentukan apakah data Anda aman atau tidak.
Enkripsi: Dua Arah
Encryption (enkripsi) adalah proses mengubah teks asli menjadi teks tersandi (ciphertext) menggunakan key (kunci). Sifatnya dua arah: siapa pun yang memegang kunci bisa membaliknya kembali menjadi teks asli (decrypt).
Contoh sederhananya adalah tulisan sandi: Anda dan teman Anda punya kamus rahasia yang sama, jadi Anda bisa saling membaca. Orang tanpa kamus itu tidak bisa.
// Konseptual: enkripsi bisa dibalik selama kita punya kunci
String encrypted = encrypt("HendroGanteng123", secretKey); // teks acak
String original = decrypt(encrypted, secretKey); // kembali plaintext
Hashing: Satu Arah
Hashing adalah proses mengubah input dengan panjang apa pun menjadi output dengan panjang tetap (hash value / digest). Sifatnya satu arah: tidak ada operasi "un-hash". Output yang sama selalu dihasilkan dari input yang sama, tapi dari output Anda tidak bisa menebak inputnya.
Yang membuatnya aman untuk password: meskipun database bocor, peretas hanya melihat deretan karakter acak yang tidak bisa diubah kembali menjadi password.
| Aspek | Enkripsi | Hashing |
|---|---|---|
| Arah | Dua arah (bisa dibalik) | Satu arah (tidak bisa dibalik) |
| Butuh kunci | Ya — key untuk menyandikan & membuka | Tidak — tidak ada kunci |
| Output | Bergantung kunci, bisa berbeda-beda | Deterministik (input sama → output sama) |
| Tujuan | Kerahasiaan saat penyimpanan/pengiriman | Verifikasi keutuhan & verifikasi password |
| Bisa dilihat kembali? | Ya, dengan kunci | Tidak, selamanya |
Kenapa Enkripsi Tidak Cukup untuk Password
Secara teori, Anda bisa mengenkripsi password sebelum menyimpannya. Masalahnya: key (kunci) enkripsi itu harus disimpan juga — biasanya di file config atau environment variable server. Artinya key dan data yang terkunci berada di sistem yang sama.
Jika server Anda diretas, peretas biasanya mendapatkan dua-duanya: database berisi password terenkripsi dan kunci untuk membukanya. Enkripsi cocok untuk data yang memang perlu dibaca kembali (misalnya nomor rekening atau detail alamat untuk pengiriman), tapi untuk password yang tidak perlu pernah dilihat lagi oleh siapa pun — termasuk sistem Anda — hash satu arah adalah pilihan yang tepat. OWASP secara eksplisit menyebutkan bahwa enkripsi saja tidak memenuhi syarat penyimpanan password yang aman karena kunci bisa jatuh ke tangan penyerang.
Hashing Naif: Mengapa MD5/SHA-1/SHA-256 Kurang Aman
"Baik, kalau begitu saya pakai hash saja." Benar, tapi tidak semua hash sama. Algoritma seperti MD5, SHA-1, dan SHA-256 memang bagus — tetapi bukan untuk password.
Mereka dirancang untuk kecepatan. Ketika Anda mendownload file dan ingin memastikan file itu tidak rusak, Anda menghitung checksum-nya dengan SHA-256, dan hasilnya langsung cocok. Cepat itu bagus untuk checksum, tapi fatal untuk password, karena serangan brute-force juga akan menjadi cepat.
Serangan yang Mengancam Hash Cepat
- Brute-force — mencoba jutaan tebakan password per detik sampai salah satunya menghasilkan hash yang sama. Karena SHA-256 bisa menghitung miliaran hash per detik dengan GPU, password sederhana bisa ketebak dalam hitungan menit atau jam.
- Rainbow table — tabel raksasa yang sudah dihitung sebelumnya, berisi pasangan
password → hashuntuk jutaan kombinasi umum. Begitu Anda melihat hash di database, tinggal cari di tabel, ketemu. Membuat rainbow table untuk hash tanpa salt itu murah, sekali buat, dipakai berulang. - Dictionary attack — variasi brute-force yang hanya mencoba kata-kata dari kamus umum beserta variasinya (
password,Password123,sandi2024, dan seterusnya).
| Algoritma | Kecepatan | Cocok untuk checksum? | Aman untuk password? |
|---|---|---|---|
MD5 | Sangat cepat | Tidak disarankan (bentrok mudah) | Tidak — bisa dipecahkan dalam hitungan detik |
SHA-1 | Cepat | Tidak disarankan (sudah lemah) | Tidak |
SHA-256 | Cepat | Ya — memeriksa keutuhan file | Tidak — terlalu cepat untuk di-brute-force |
BCrypt | Sengaja lambat | Tidak perlu | Ya |
Argon2id | Lambat & butuh banyak memori | Tidak perlu | Ya |
Hash cepat seperti SHA-256 itu seperti pintu tanpa kunci — kelihatan tertutup, tapi peretas tinggal mendorong. Algoritma untuk password harus sengaja dibuat lambat agar tebakan per detiknya sedikit.
Solusi Modern: Hash yang Lambat & Memory-Hard
Password perlu di-hash dengan algoritma yang sengaja mahal untuk dihitung: lambat dalam waktu proses dan, untuk yang modern, boros memori. Dengan begitu, peretas yang mencoba brute-force hanya bisa mencoba beberapa ratus percobaan per detik, bukan miliaran. Ada tiga keluarga algoritma yang direkomendasikan OWASP: Argon2, scrypt, BCrypt, dan PBKDF2.
BCrypt: Salt Otomatis + Cost Factor
BCrypt adalah algoritma hashing password yang dibuat pada 1999 oleh Niels Provos dan David Mazières, dirancang khusus agar lambat dan mahal untuk dihitung (referensi). Dua fitur andalannya:
- Salt otomatis — setiap kali Anda meng-hash password, BCrypt membuat
salt(nilai acak) baru secara otomatis dan menyimpannya di dalam hasil hash. Dua password yang sama pun menghasilkan hash yang berbeda. - Cost factor — parameter yang mengatur berapa kali proses internal diulang. Semakin tinggi angkanya, semakin lama waktu yang dibutuhkan untuk menghasilkan satu hash.
Hasil hash BCrypt berupa string dengan format $2a$10$.... Mari bedah:
$2a$10$c2fhOQy0jNqBTwLmRs2hY.6Sj1zWjFNvUQehO8TrwZ0wT4Q8jZb4O
└─┬┘ └┬┘ └─────────────────────────────┬──────────────────────────────┘
version cost salt (22 char) + hash (31 char)
| Bagian | Contoh | Penjelasan |
|---|---|---|
| Versi algoritma | $2a$ | Menandakan varian BCrypt ($2a$, $2b$, atau $2y$) |
| Cost factor | $10$ | 10 = 2¹⁰ iterasi. Menaikkan 1 membuat proses 2× lebih lambat |
| Salt | 22 karakter | Nilai acak unik per hash, mengalahkan rainbow table |
| Hash aktual | 31 karakter | Hasil akhir yang akan dibandingkan saat login |
Seluruh paket (versi + cost + salt + hash) tersimpan dalam satu string. Itu sebabnya hash BCrypt selalu sepanjang 60 karakter dan mudah dikenali. Saat verifikasi, BCrypt membaca cost dan salt dari string tersebut, lalu menghitung ulang dengan parameter yang sama.
Alternatif Modern
| Algoritma | Jenis | Kekuatan utama | Catatan |
|---|---|---|---|
Argon2id | Pemenang Password Hashing Competition (2015) | Memory-hard — boros RAM, sulit dipercepat dengan GPU | Pilihan paling direkomendasikan saat ini |
scrypt | Memory-hard | Memerlukan banyak memori sehingga GPU tidak berguna | Direkomendasikan oleh RFC 7914 |
BCrypt | Lambat secara iterasi | Implementasi luas, otomatis di semua framework | Standar de facto, dipakai Spring Security |
PBKDF2 | Iteratif (bukan memory-hard) | Disetujui NIST / FIPS | Butuh salt besar & iterasi tinggi; lemah terhadap GPU |
Salt dan Work Factor: Dua Konsep Kunci
Salt
Salt adalah deretan nilai acak yang ditambahkan ke password sebelum di-hash. Tujuannya sederhana: memastikan bahwa dua pengguna dengan password yang sama menghasilkan hash yang berbeda.
Tanpa salt, dua pengguna berpassword "password123" akan punya hash yang identik. Peretas yang melihat dua hash sama langsung tahu kedua akun memakai password yang sama — dan satu rainbow table bisa membongkar semuanya sekaligus. Dengan salt yang unik per pengguna, setiap hash harus dipecahkan satu per satu. Wikipedia mencatat bahwa salt adalah salah satu fondasi desain BCrypt justru untuk membunuh efektivitas rainbow table.
Aturannya: satu pengguna, satu salt unik. Jangan pernah memakai salt global yang sama untuk semua pengguna. Salt bukan rahasia — ia boleh disimpan terbuka di sebelah hash (di BCrypt memang sudah jadi satu paket). Kekuatannya bukan dari disembunyikan, tapi dari membuat setiap hash unik dan tidak bisa dipakai ulang.
Work Factor / Cost
Work factor (atau cost factor) adalah jumlah kerja yang harus dilakukan untuk menghasilkan satu hash. Semakin tinggi, semakin lambat hashing — dan semakin lambat pula brute-force. Di BCrypt, cost = 10 berarti 2¹⁰ = 1.024 iterasi; cost = 12 berarti 4.096 iterasi, dua kali lebih lama lagi.
Namun ini selalu soal tradeoff:
| Cost factor | Waktu hashing (estimasi) | Keamanan | Pengalaman pengguna |
|---|---|---|---|
4–6 | Milidetik | Lemah — terlalu cepat di-brute-force | Responsif |
10–12 | 50–300 ms | Kuat — standar industri | Masih terasa instan |
13+ | >500 ms | Sangat kuat | Mulai terasa lambat, banjir login bisa membebani CPU |
Aturan praktisnya: setel cost setinggi mungkin selama pengguna masih merasakan login yang lancar (sekitar 100–300 ms). OWASP menyarankan cost/iterasi yang membuat hashing memakan waktu sekitar 100 ms lebih. Dan karena hardware terus makin cepat, cost harus dinaikkan dari waktu ke waktu — itulah alasan mengapa framework menyimpan cost di dalam string hash: agar hash lama tetap bisa diverifikasi meski cost global sudah dinaikkan.
Implementasi di Spring Security
Spring Security sudah menyediakan BCryptPasswordEncoder — sebuah PasswordEncoder yang membungkus BCrypt. Anda tinggal menggunakannya, tidak perlu menulis algoritma sendiri.
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
public class UserService {
private final BCryptPasswordEncoder passwordEncoder =
new BCryptPasswordEncoder(12); // cost factor 12
// 1. REGISTER: encode saat menyimpan
public User register(String email, String rawPassword) {
String hashed = passwordEncoder.encode(rawPassword); // selalu hasil baru karena salt acak
User user = new User();
user.setEmail(email);
user.setPasswordHash(hashed); // simpan HASH, bukan password asli
return userRepository.save(user);
}
// 2. LOGIN: matches saat memverifikasi
public boolean login(String email, String rawPassword) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new RuntimeException("User tidak ditemukan"));
return passwordEncoder.matches(rawPassword, user.getPasswordHash());
}
}
Poin penting dari kode di atas:
encode(rawPassword)menghasilkan string 60 karakter seperti$2a$12$.... Setiap pemanggilan menghasilkan hash berbeda (karena salt acak), tapimatches()tetap benar karena salt terbaca dari string hash itu sendiri.matches(rawPassword, hash)menghitung ulang hash dari input pengguna dengan salt & cost yang tersimpan, lalu membandingkannya dengan hash di database. Password asli tidak pernah keluar dari proses ini.
Prefix {bcrypt} dan DelegatingPasswordEncoder
Mulai Spring Security 5, defaultnya bukan lagi BCryptPasswordEncoder langsung, melainkan DelegatingPasswordEncoder. Ia menyimpan hasilnya dengan awalan (prefix) yang menunjukkan algoritma mana yang dipakai:
{bcrypt}$2a$10$s8F3QmX9bO5Rw...
Format {id}encodedPassword ini memungkinkan Anda menyimpan hash dari berbagai algoritma dalam satu sistem, lalu bertahap memigrasi hash lama ke algoritma baru tanpa merusak login yang sudah ada. Kapan pun Anda melihat prefix seperti {bcrypt}, {pbkdf2}, atau {argon2}, itu adalah format delegating encoder. Mengganti {id} dan menambahkan encoder baru sambil mempertahankan encoder lama adalah strategi migrasi yang aman — detail lengkap ada di dokumentasi password storage Spring Security.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.DelegatingPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// Default baru: delegating, prefix {bcrypt}
String defaultId = "bcrypt";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put(defaultId, new BCryptPasswordEncoder(12));
encoders.put("argon2", new org.springframework.security.crypto.argon2.Argon2PasswordEncoder());
return new DelegatingPasswordEncoder(defaultId, encoders);
}
}
Keuntungan delegating encoder: Anda tidak perlu memaksa semua pengguna ganti password saat upgrade algoritma. Hash lama tetap diverifikasi oleh encoder lamanya, sementara pengguna baru — atau yang ganti password — otomatis tersimpan dengan algoritma baru.
Validasi dan Penyimpanan Password yang Aman
Meng-hash saja belum lengkap. Ada sejumlah aturan penyimpanan & validasi yang harus diikuti agar tidak menciptakan celah baru.
| Aturan | Alasan |
|---|---|
Simpan hanya hash, jangan pernah plaintext | Jika database bocor, password tidak terbaca |
Simpan hash di kolom bertipe luas (varchar(60) / varchar(255)) | Hash BCrypt butuh 60 karakter; jangan batasi dengan kolom kecil |
| Jangan pernah log password | Log server bisa bocor atau terbaca internal; password di log = bencana |
| Jangan batasi panjang password, kecuali minimum | Panjang adalah salah satu kekuatan utama password; batasi hanya min 12 karakter |
| Set cost tinggi secara berkala | Hardware makin cepat; naikkan cost seiring waktu |
Soal panjang minimum, OWASP merekomendasikan password minimal 12 karakter, dan menyarankan aplikasi tidak membatasi password pengguna di atas batas itu. Password panjang alami (ilovemendaki-gunung-selama-liburan) jauh lebih sulit dipecahkan daripada password pendek yang diisi simbol (P@ss1!) — persisnya yang diandalkan brute-force.
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 12) String password
) {
// min 12 karakter, tanpa batas maksimum yang kaku
}
Dari sisi database, pastikan kolomnya memadai:
CREATE TABLE app_user (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash VARCHAR(60) NOT NULL, -- hash BCrypt = tepat 60 karakter
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
Praktik Pelengkap di Sekitar Login
Hashing yang bagus melindungi password yang bocor, tapi tidak menghentikan semua serangan. Beberapa praktik di sekitarnya wajib ikut dipasang:
- Rate limiting di endpoint login — batasi jumlah percobaan per akun atau per IP (misalnya 5 percobaan gagal dalam 15 menit). Ini mematikan brute-force jarak jauh, karena peretas tidak bisa mencoba password terus-menerus.
- Respons login yang konsisten — banyak sistem membedakan "email tidak terdaftar" dengan "password salah". Ini membantu peretas menebak akun mana yang valid (
user enumeration). Pilih kebijakan: jawab401yang sama untuk kedua kasus, atau justru mengizinkan pendaftaran mendeteksi email duplikat — konsisten saja. - MFA (Multi-Factor Authentication) — hash melindungi password, MFA melindungi akun. Walau password bocor, peretas masih butuh faktor kedua (OTP, aplikasi autentikator). Ini pelengkap terkuat setelah hashing yang benar.
- Jangan reset password secara otomatis tanpa verifikasi — endpoint "lupa password" adalah pintu masuk favorit peretas. Pastikan selalu butuh bukti kepemilikan email/telepon.
Alur Register dan Login (Diagram)
Untuk memastikan gambaran utuhnya jelas, berikut alur register dan login dalam satu diagram:
REGISTER
User mengetik password (plaintext)
|
v
+---------------------+
| password + salt acak |
+---------------------+
|
v
BCrypt: hash berulang 2^cost kali
|
v
Simpan di DB: $2a$12$<salt><hash> <-- hanya ini yang disimpan
|
v
Password asli dibuang. Tidak ada catatan di mana pun.
LOGIN
User mengetik password (plaintext)
|
v
Ambil hash dari DB: $2a$12$<salt><hash>
|
v
BCrypt: hash ulang input dengan salt & cost yang sama
|
v
Bandingkan hash baru vs hash di DB
|
+----+--------+---+
| cocok? |
+----+---+------+
cocok tidak
| |
v v
Login sukses Tolak (401), hitung percobaan gagal
Perhatikan: plaintext hanya eksis selama proses berlangsung di memori — saat register ia di-hash dan dibuang, saat login ia dipakai untuk dibandingkan lalu dibuang. Ia tidak pernah mampir ke database.
Ringkasan
Password tidak boleh disimpan dalam bentuk yang bisa dibaca siapa pun. Gunakan hash satu arah, bukan encryption yang bisa dibalik dengan kunci. Jangan pakai hash cepat seperti MD5 atau SHA-256 untuk password — terlalu mudah di-brute-force dan rentan rainbow table. Pakailah algoritma yang sengaja lambat dan memory-hard: BCrypt adalah pilihan standar yang tersedia di hampir semua framework termasuk Spring Security, dengan salt otomatis per pengguna dan cost factor yang bisa dinaikkan seiring waktu. Lengkapi dengan: hash di kolom varchar(60), tanpa log password, minimum 12 karakter, rate limiting di login, respons yang tidak membocorkan informasi, dan MFA sebagai lapisan tambahan. Ingat prinsipnya: database Anda boleh bocor, password pengguna tidak boleh terbaca.
Lanjut membaca
- JWT (JSON Web Token) untuk Autentikasi — lanjutan logis: setelah password terverifikasi, bagaimana sesi/token dikeluarkan ke klien.
- Anatomi Protokol HTTP & Siklus Request-Response — pahami status code
401vs403yang dipakai untuk login gagal vs tidak berhak. - Resilient Microservices with Spring Boot — untuk mempelajari hardening backend secara lebih luas.