Lưu mật khẩu đúng cách: bcrypt và argon2
1. Tổng quan
Lưu mật khẩu sai cách là một trong những lỗ hổng phổ biến nhất khiến dữ liệu người dùng bị lộ khi database bị tấn công. Bài này giúp bạn hiểu vì sao không nên dùng MD5/SHA256 thuần để băm mật khẩu, và cách dùng BCryptPasswordEncoder cùng Argon2PasswordEncoder trong Spring Security. Đọc xong, bạn sẽ cấu hình được một PasswordEncoder an toàn, viết test kiểm tra hành vi, và biết chọn thuật toán nào cho dự án của mình.
2. Vì sao không nên băm mật khẩu bằng SHA-256 thuần
Các hàm băm mật mã như SHA-256, SHA-512 được thiết kế để chạy càng nhanh càng tốt. Đó là ưu điểm khi kiểm tra tính toàn vẹn file, nhưng lại là thảm họa khi dùng cho mật khẩu.
import java.security.MessageDigest;
import java.nio.charset.StandardCharsets;
public class NaiveHasher {
public static String sha256(String password) throws Exception {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(password.getBytes(StandardCharsets.UTF_8));
return java.util.HexFormat.of().formatHex(hash);
}
}
Hàm trên băm được hàng tỷ mật khẩu mỗi giây trên một GPU tầm trung. Kẻ tấn công chỉ cần có bảng rainbow table hoặc brute-force là dò ra mật khẩu gốc, đặc biệt với các mật khẩu phổ biến như 123456, password1.
Hai vấn đề cốt lõi:
- Không có salt: hai user cùng mật khẩu sẽ ra cùng hash, dễ bị tấn công bằng rainbow table.
- Quá nhanh: không có cách nào “làm chậm” kẻ tấn công.
Lưu ý: Đừng tự chế thêm salt thủ công rồi nối chuỗi vào SHA-256. Đó vẫn là giải pháp yếu vì thiếu “work factor” — độ khó tính toán không tăng theo thời gian phần cứng mạnh lên.
3. Password hashing function là gì
Các thuật toán như bcrypt, scrypt, argon2 được thiết kế riêng cho việc băm mật khẩu, với ba đặc điểm:
- Salt tự động: mỗi lần băm sinh salt ngẫu nhiên, nhúng luôn vào chuỗi hash output.
- Cố ý chậm (work factor): có tham số để tăng độ khó, chống brute-force khi phần cứng mạnh lên.
- Memory-hard (với argon2/scrypt): tốn nhiều RAM, khiến việc chạy song song trên GPU/ASIC kém hiệu quả.
Kết quả một chuỗi bcrypt hash trông như sau:
$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
Cấu trúc: $<version>$<cost>$<salt+hash base64>. Ở đây 10 là cost factor (2^10 vòng lặp).
4. Dùng bcrypt với Spring Security
4.1. Dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
BCryptPasswordEncoder đã có sẵn trong spring-security-crypto, không cần thêm gì khác.
4.2. Khai báo bean
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.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12); // cost factor = 12
}
}
Tham số 12 quyết định số vòng lặp là 2^12. Cost factor cao hơn → chậm hơn → an toàn hơn, nhưng cũng tốn CPU hơn khi login. Giá trị 10–12 là hợp lý cho hầu hết ứng dụng năm 2026.
4.3. Sử dụng khi đăng ký và đăng nhập
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
@Service
public class UserRegistrationService {
private final PasswordEncoder passwordEncoder;
private final UserRepository userRepository;
public UserRegistrationService(PasswordEncoder passwordEncoder, UserRepository userRepository) {
this.passwordEncoder = passwordEncoder;
this.userRepository = userRepository;
}
public void register(String email, String rawPassword) {
String hashed = passwordEncoder.encode(rawPassword);
userRepository.save(new Customer(email, hashed));
}
public boolean authenticate(String email, String rawPassword) {
Customer customer = userRepository.findByEmail(email)
.orElseThrow(() -> new IllegalArgumentException("Email không tồn tại"));
return passwordEncoder.matches(rawPassword, customer.getPasswordHash());
}
}
encode()sinh salt ngẫu nhiên mỗi lần gọi, nên cùng mộtrawPasswordsẽ ra hash khác nhau mỗi lần — đây là hành vi đúng, không phải bug.matches()tự tách salt từ hash lưu trong DB, băm lại mật khẩu nhập vào rồi so sánh, không cần bạn tự quản lý salt.
4.4. Test hành vi
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class BcryptPasswordEncoderTest {
private final BCryptPasswordEncoder encoder = new BCryptPasswordEncoder(10);
@Test
void shouldGenerateDifferentHashesForSamePassword() {
String hash1 = encoder.encode("S3cretPass!");
String hash2 = encoder.encode("S3cretPass!");
assertThat(hash1).isNotEqualTo(hash2);
assertThat(encoder.matches("S3cretPass!", hash1)).isTrue();
assertThat(encoder.matches("S3cretPass!", hash2)).isTrue();
}
@Test
void shouldRejectWrongPassword() {
String hash = encoder.encode("S3cretPass!");
assertThat(encoder.matches("WrongPass!", hash)).isFalse();
}
}
Test này xác nhận hai điều quan trọng: salt ngẫu nhiên khiến hash khác nhau, và matches() vẫn nhận diện đúng mật khẩu gốc dù hash khác nhau.
Lưu ý: bcrypt có giới hạn độ dài input là 72 byte. Nếu mật khẩu dài hơn, phần vượt quá bị cắt bỏ âm thầm. Với mật khẩu thông thường (dưới 72 ký tự) thì không vấn đề gì, nhưng nếu bạn cho phép passphrase rất dài, hãy cân nhắc argon2.
5. Dùng argon2 — lựa chọn hiện đại hơn
Argon2 thắng cuộc thi Password Hashing Competition năm 2015 và được OWASP khuyến nghị là lựa chọn đầu tiên hiện nay nhờ khả năng chống GPU-cracking tốt hơn bcrypt (memory-hard).
5.1. Dependency
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk18on</artifactId>
<version>1.78.1</version>
</dependency>
Spring Security dùng Bouncy Castle làm provider cho Argon2PasswordEncoder, nên cần thêm dependency này.
5.2. Khai báo bean
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
// saltLength=16, hashLength=32, parallelism=1, memory=1<<16 (64MB), iterations=3
return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
}
}
Factory method defaultsForSpringSecurity_v5_8() trả về cấu hình tham số đã được team Spring Security kiểm chứng là an toàn cho phần lớn ứng dụng, bạn không cần tự tính toán memory/iterations.
5.3. So sánh output
$argon2id$v=19$m=65536,t=3,p=1$c29tZXNhbHQ$5t1h3u7K3iC2rT0...
Cấu trúc gồm biến thể (argon2id), version, tham số memory (m), iterations (t), parallelism (p), rồi mới tới salt và hash — minh bạch hơn bcrypt vì expose rõ cost.
5.4. Test so sánh hai encoder
import org.junit.jupiter.api.Test;
import org.springframework.security.crypto.argon2.Argon2PasswordEncoder;
import static org.assertj.core.api.Assertions.assertThat;
class Argon2PasswordEncoderTest {
private final Argon2PasswordEncoder encoder =
Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8();
@Test
void shouldVerifyCorrectPassword() {
String hash = encoder.encode("MyP@ssw0rd123");
assertThat(encoder.matches("MyP@ssw0rd123", hash)).isTrue();
assertThat(encoder.matches("wrong", hash)).isFalse();
}
}
6. Hỗ trợ nhiều encoder cùng lúc với DelegatingPasswordEncoder
Thực tế, hệ thống của bạn có thể cần đổi thuật toán theo thời gian (ví dụ migrate từ bcrypt sang argon2) mà không phá vỡ các hash cũ. Spring Security cung cấp sẵn giải pháp này.
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.factory.PasswordEncoderFactories;
import org.springframework.security.crypto.password.PasswordEncoder;
@Configuration
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
}
createDelegatingPasswordEncoder() tạo một encoder biết đọc prefix {bcrypt}, {argon2}, {noop}… trong chuỗi hash để chọn đúng thuật toán giải mã. Mặc định, nó dùng bcrypt để encode mật khẩu mới, nhưng vẫn matches() đúng với hash cũ được tạo bằng thuật toán khác.
{bcrypt}$2a$10$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
{argon2}$argon2id$v=19$m=65536,t=3,p=1$...
Nhờ prefix này, bạn migrate dần dần: user cũ vẫn login được, user đăng ký mới hoặc đổi mật khẩu sẽ được băm lại bằng thuật toán mới nhất.
7. So sánh nhanh bcrypt và argon2
| Tiêu chí | bcrypt | argon2 |
|---|---|---|
| Năm ra đời | 1999 | 2015 (PHC winner) |
| Chống GPU/ASIC | Khá | Tốt hơn (memory-hard) |
| Giới hạn input | 72 byte | Không giới hạn thực tế |
| Độ phổ biến | Rất cao, hỗ trợ mọi nơi | Ngày càng phổ biến |
| OWASP khuyến nghị | Lựa chọn thứ 2 | Lựa chọn đầu tiên |
Nếu dự án mới và không bị ràng buộc bởi hệ thống cũ, chọn argon2id (biến thể argon2id là mặc định trong Spring Security, cân bằng giữa chống side-channel và GPU attack). Nếu hệ thống đã dùng bcrypt ổn định nhiều năm, không nhất thiết phải migrate ngay — tăng cost factor bcrypt lên 12–14 vẫn đủ an toàn cho phần lớn ứng dụng.
8. Những lỗi thường gặp
- Tự implement salt + SHA-256: như đã nói ở mục 2, thiếu work factor nên vẫn yếu trước brute-force hiện đại.
- Dùng cost factor quá thấp:
BCryptPasswordEncoder()không tham số mặc định là cost 10, nhưng nếu bạn hạ xuống 4–5 để “cho nhanh” lúc test, dễ quên đổi lại khi deploy production. - So sánh hash bằng
String.equals(): không bao giờ tự so sánh chuỗi hash thủ công — luôn dùngmatches()củaPasswordEncoder, vì các thư viện này so sánh theo kiểu constant-time, tránh timing attack. - Lưu salt riêng một cột DB: không cần thiết, cả bcrypt lẫn argon2 đã nhúng salt vào chuỗi hash output.
- Không giới hạn rate login: dù hash có mạnh đến đâu, thiếu rate limiting hoặc account lockout vẫn khiến brute-force online khả thi.
Lưu ý: Không bao giờ dùng
{noop}encoder (plaintext) trong production. Nó chỉ nên xuất hiện trong test hoặc demo cục bộ.
9. Kết luận
Băm mật khẩu đúng cách nghĩa là dùng thuật toán được thiết kế riêng cho việc này — bcrypt hoặc argon2 — thay vì các hàm băm nhanh như SHA-256. Spring Security giúp việc này đơn giản: chỉ cần khai báo một bean PasswordEncoder, gọi encode() khi lưu và matches() khi xác thực, không cần tự quản lý salt. Nếu cần migrate linh hoạt giữa các thuật toán, DelegatingPasswordEncoder là lựa chọn an toàn và tương thích ngược. Dù chọn bcrypt hay argon2, hãy nhớ rằng hashing mạnh chỉ là một lớp phòng thủ — kết hợp thêm rate limiting và MFA mới tạo nên hệ thống xác thực thực sự an toàn.