Mật khẩu được 'băm' thế nào trong database — và vì sao 'quên mật khẩu' không gửi mật khẩu cũ
Web đúng chuẩn KHÔNG lưu password thật của bạn — chỉ lưu một bản 'băm nhuyễn' không thể khôi phục ngược. Hiểu cơ chế này giúp bạn phân biệt SaaS chuẩn vs nghiệp dư, biết tại sao không web nào nên 'gửi lại mật khẩu cũ' qua email, và đặt câu hỏi đúng khi audit security.
Mục lục bài viết(16)

Hash function — biến input bất kỳ độ dài thành output cố định ('digest'). Đặc tính: (1) cùng input luôn ra cùng output; (2) input chỉ đổi 1 ký tự thì output đổi toàn diện; (3) không thể từ output tính ngược ra input. Đây là nền tảng toán học của password hashing. Nguồn: Wikimedia Commons (public domain).
Hiểu đơn giản nhất
Hash password giống nướng bánh mì.
- Bạn có bột mì (password gốc:
matkhau123) - Cho vào lò nướng (hash function:
bcrypt) - Ra ổ bánh mì (hash output:
$2b$12$N9qo8uLOickgx2ZMRZoMy...)
Bạn không thể "nướng ngược" bánh mì ra bột. Đó là cơ chế bảo vệ password.
Khi user login lần sau:
- User nhập
matkhau123→ server nướng lại lần nữa → so với "ổ bánh" đã lưu trong DB - Khớp → cho login. Không khớp → từ chối.
Server không cần biết bột gốc là gì — chỉ cần verify "nướng lại có ra ổ giống không". DB bị hack → hacker chỉ thấy "ổ bánh", không phục hồi được bột.
3 thuật ngữ chính cần phân biệt:
| Thuật ngữ | Vai trò | Có đảo ngược được không |
|---|---|---|
| Encrypt | Khoá data lại bằng key | Có — ai có key thì giải được |
| Hash | Băm data thành chuỗi cố định | Không — toán học một chiều |
| Encode (base64, hex) | Đổi định dạng | Có — ai cũng decode được, KHÔNG phải bảo mật |
Tại sao bạn cần biết
- Phân biệt SaaS chuẩn vs nghiệp dư trong 1 câu hỏi. "Sếp, nếu khách quên password thì lấy được password cũ ra không?" — đáp "Có" = báo động đỏ.
- Đọc được audit security report. "Web dùng MD5 cho password" là lỗi Critical OWASP. Bcrypt với cost ≥ 12 mới được pass.
- Hiểu được giá trị 2FA. Vì biết hash không hoàn hảo (rainbow table + GPU attack), 2FA là lớp bảo vệ thứ 2 — kể cả hash bị crack, hacker vẫn cần điện thoại của bạn.
- Đặt yêu cầu đúng khi thuê dev. "Phải dùng Argon2id hoặc bcrypt cost 12+, không bao giờ MD5/SHA1" — câu này trong spec tránh được lỗi #1 trong OWASP Top 10.
- Hiểu vì sao login chậm hơn các API khác. Hash chậm có chủ ý — 200-500ms là bình thường. Đừng "optimize" bằng cách giảm cost.
Tại sao KHÔNG được lưu password plaintext
Nếu DB của web bị leak (Shopee 2021, Vietnam Airlines 2022, MyAladdinz 2023 — đều là leak thật):
| Web lưu kiểu gì | Hậu quả khi leak |
|---|---|
| Plaintext (text thường) | Toàn bộ password lộ ngay → mọi tài khoản khác cùng password của user cũng bay |
| Base64 / hex | Lộ ngay — 1 dòng code decode |
| MD5 / SHA-1 không salt | Hacker dùng rainbow table → 80% password phổ biến crack < 1 phút |
| MD5 / SHA-256 có salt | Crack chậm hơn nhưng GPU RTX 4090 vẫn được 80% trong 1-2 tuần |
| Bcrypt cost 10 | Crack 1 password ~1 năm CPU, ~1 tuần GPU farm |
| Bcrypt cost 12 | Crack ~16 năm CPU, ~4 tháng GPU farm |
| Argon2id mạnh | Hiện không có attack hiệu quả nào ngoài brute-force password weak |
⚠️ Quy tắc cực kỳ quan trọng: "Hash từ Bcrypt cost 12 trở lên" KHÔNG bảo vệ được password yếu (123456, password, qwerty). Vì hash đúng password yếu thì ai cũng đoán được. Đó là lý do web tốt buộc password ≥ 8 ký tự + có chữ + số.
Hash function lý tưởng cần gì
Một hash function tốt phải đáp ứng:
1. Deterministic (cùng input → cùng output)
bcrypt("matkhau123", salt="abc") → "$2b$12$N9qo..."
bcrypt("matkhau123", salt="abc") → "$2b$12$N9qo..." (luôn vậy)
Nếu không deterministic → không verify được login.
2. Pre-image resistance (không thể tìm input từ output)
Cho hash 5f4dcc3b5aa765d61d8327deb882cf99, không có cách nào nhanh hơn brute-force để tìm input gốc.
3. Avalanche effect (input đổi 1 bit → output đổi >50% bit)
hash("password") → 5f4dcc3b5aa765d61d8327deb882cf99
hash("password1") → 7c6a180b36896a0a8c02787eeafb0e4c
Output hoàn toàn khác. Hacker không thể "guess" gần gần.
4. Slow (chậm chủ ý) — quan trọng nhất với password
Hash function thông thường (SHA-256) chạy 1 tỷ lần/giây trên GPU. Quá nhanh cho password. Bcrypt/Argon2 thiết kế để chạy chậm — 100-500ms mỗi lần — để hacker không scale được.
Salt — gia vị chống rainbow table
Rainbow table = database khổng lồ chứa sẵn hash của mọi password phổ biến. Hacker leak DB → match hash với rainbow table → ra password gốc trong vài giây.
Salt giải quyết: thêm chuỗi ngẫu nhiên vào password trước khi hash.
User A: password = "matkhau123", salt = "xyz789"
hash = bcrypt("matkhau123" + "xyz789") = "$2b$12$N9qo..."
User B: password = "matkhau123", salt = "abc456" ← salt KHÁC
hash = bcrypt("matkhau123" + "abc456") = "$2b$12$2t5Px..." ← hash KHÁC
Mỗi user salt khác nhau → rainbow table vô dụng (hacker phải build table riêng cho mỗi salt). Salt lưu chung với hash trong DB — không cần bí mật, chỉ cần unique.
Bcrypt/Argon2 tự generate salt và lưu chung trong chuỗi hash output. Bạn chỉ cần gọi bcrypt.hash(password) — library lo phần còn lại.
So sánh thuật toán hash password — chọn cái nào
| Thuật toán | Năm | Khuyến nghị | Khi nào dùng |
|---|---|---|---|
| MD5 | 1992 | ❌ TUYỆT ĐỐI KHÔNG | Crack trong giây — chỉ dùng cho checksum file |
| SHA-1 | 1995 | ❌ TUYỆT ĐỐI KHÔNG | Collision attack từ 2017, deprecated |
| SHA-256 thuần | 2001 | ❌ KHÔNG cho password | Quá nhanh, dễ rainbow table attack — dùng SHA-256 cho file checksum OK |
| PBKDF2 | 2000 | ⚠️ Chấp nhận được | Standard NIST/FIPS, dùng khi yêu cầu compliance — set 600.000 iterations |
| Bcrypt | 1999 | ✅ Tốt | Battle-tested, library mọi ngôn ngữ — set cost ≥ 12 |
| scrypt | 2009 | ✅ Tốt | Chống GPU tốt hơn bcrypt — better-auth dùng mặc định |
| Argon2id | 2015 | ✅ TỐT NHẤT | OWASP khuyến nghị, thắng PHC 2015 — params: m=19MB, t=2, p=1 |
⚠️ KHÔNG tự code hash function. Luôn dùng library tested (bcrypt, @node-rs/argon2). Tự code = chắc chắn có lỗ.
Ví dụ thực tế: Bcrypt trong code Node.js (TypeScript)
Code đăng ký user:
import bcrypt from "bcrypt";
async function register(email: string, plainPassword: string) {
// Hash với cost 12 — auto generate salt
const hash = await bcrypt.hash(plainPassword, 12);
// Lưu hash vào DB (KHÔNG lưu plainPassword)
await db.insert(users).values({ email, passwordHash: hash });
// hash trong DB trông như:
// $2b$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy
// │ │ │ │
// │ │ │ └─ Hash thực
// │ │ └─ Salt (22 ký tự ngẫu nhiên)
// │ └─ Cost factor (12)
// └─ Algorithm version (2b)
}Code login:
async function login(email: string, plainPassword: string) {
const user = await db.query.users.findFirst({ where: eq(users.email, email) });
if (!user) return { error: "Invalid credentials" };
// bcrypt.compare tự đọc salt + cost từ hash đã lưu
const match = await bcrypt.compare(plainPassword, user.passwordHash);
if (!match) return { error: "Invalid credentials" };
return { user };
}3 điểm quan trọng:
hashlưu chứa salt + cost + algorithm — không cần lưu riêngbcrypt.comparetự dùng đúng salt, cost — bạn không cần biết- Error message giống nhau cho "email không có" và "password sai" — chống account enumeration (xem audit security 2026-05-22)
"Quên mật khẩu" hoạt động thế nào (đúng cách)
Vì server không có password gốc (chỉ có hash), không thể "gửi password cũ". Flow đúng:
- User bấm "Quên mật khẩu" → nhập email
- Server tạo reset token ngẫu nhiên (chuỗi 32+ ký tự), lưu vào bảng
password_resetsvớiexpires_at1-24 giờ - Server gửi email chứa link:
https://example.com/reset?token=abc123xyz... - User click link → trang reset password → nhập password mới × 2
- Server verify token chưa expire → hash password mới → update
users.password_hash - Invalidate token (xoá row hoặc set
used_at) — token chỉ dùng 1 lần
⚠️ Reset token PHẢI ngẫu nhiên mạnh (crypto.randomBytes(32).toString('hex')), không phải random() của JavaScript thường.
Defense in depth: hash + HTTPS + 2FA
Hash chỉ là một lớp. Hệ thống bảo mật mạnh dùng nhiều lớp:
| Lớp | Bảo vệ gì | Nếu thiếu thì sao |
|---|---|---|
| HTTPS | Truyền password qua mạng | WiFi công cộng sniff được plaintext |
| Password hash (Bcrypt 12+) | Lưu password trong DB | DB leak → toàn bộ password lộ |
| Password policy (≥ 8 ký tự + complex) | User chọn password yếu | 123456 hash 100 lần vẫn crack được |
| Rate limit login (5 lần/phút) | Brute force online | Hacker thử 1 tỷ password qua API |
| 2FA (TOTP, SMS, FIDO key) | Hash bị crack | Hacker có password nhưng không có phone |
| Anomaly detection (đăng nhập IP lạ) | Account takeover | User không phát hiện sớm |
vietcodex.com hiện có lớp 1-4. Lớp 5 (2FA) đang trong roadmap better-auth plugin.
Cái gì có thể sai
| Vấn đề | Nguyên nhân | Cách fix |
|---|---|---|
| Login chậm 1-3 giây | Cost bcrypt quá cao (14+) | Giảm cost xuống 12 — vẫn an toàn 5+ năm tới |
| DB leak, password lộ hết | Lưu plaintext hoặc MD5 không salt | Migrate sang Bcrypt — re-hash khi user login lại |
| Hacker brute force qua login API | Không rate limit | Thêm rate-limit 5/phút/IP (đã làm trong vietcodex audit) |
| Reset token bị guess | Dùng Math.random() thay crypto.randomBytes | Dùng crypto.randomBytes(32).toString('hex') |
| Email reset bị forward → ai cũng dùng được | Token không có expiry | Set expires_at = NOW + 1 giờ, check trước khi accept |
| Password policy quá lỏng | User chọn 123456 đậm chất Việt Nam | Bắt min 8 ký tự + check vs danh sách 10k password phổ biến (HaveIBeenPwned API) |
Tóm tắt 1 dòng
Hash password = "nướng bánh mì" — không thể đảo ngược. Bcrypt cost 12+ hoặc Argon2id là chuẩn 2026. Mỗi user có salt riêng để chống rainbow table. KHÔNG bao giờ lưu plaintext, MD5, SHA-1 cho password. Web "gửi password cũ qua email" = đỏ cờ. Hash + HTTPS + Rate limit + 2FA là 4 lớp tối thiểu cho hệ thống login chuẩn 2026.
Đọc tiếp
- Cookies, Session, Token — 3 cách web nhớ bạn — sau khi verify hash xong, server tạo session/token
- HTTPS, SSL, TLS — vì sao web không có ổ khoá xanh thì khách bỏ chạy — bảo vệ password lúc TRUYỀN (hash chỉ bảo vệ lúc LƯU)
- Database là gì? So sánh dễ hiểu với Excel — nơi lưu password hash + reset tokens
- Webhook là gì — cách Casso, Stripe, Zalo gõ cửa web của bạn — webhook có HMAC signature, cùng concept hash (sắp có)