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.

10 phút đọcCập nhật 2026-05-22
Nghe bài viết
Để Claude đọc bài cho bạn — vừa nghe vừa làm việc khác
Mục lục bài viết(16)
Sơ đồ hash function — input bất kỳ độ dài cho ra output cố định không đảo ngược được

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
EncryptKhoá data lại bằng key — ai có key thì giải được
HashBăm data thành chuỗi cố địnhKhông — toán học một chiều
Encode (base64, hex)Đổi định dạng — 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 / hexLộ ngay — 1 dòng code decode
MD5 / SHA-1 không saltHacker dùng rainbow table → 80% password phổ biến crack < 1 phút
MD5 / SHA-256 có saltCrack chậm hơn nhưng GPU RTX 4090 vẫn được 80% trong 1-2 tuần
Bcrypt cost 10Crack 1 password ~1 năm CPU, ~1 tuần GPU farm
Bcrypt cost 12Crack ~16 năm CPU, ~4 tháng GPU farm
Argon2id mạnhHiệ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ánNămKhuyến nghịKhi nào dùng
MD51992❌ TUYỆT ĐỐI KHÔNGCrack trong giây — chỉ dùng cho checksum file
SHA-11995❌ TUYỆT ĐỐI KHÔNGCollision attack từ 2017, deprecated
SHA-256 thuần2001❌ KHÔNG cho passwordQuá nhanh, dễ rainbow table attack — dùng SHA-256 cho file checksum OK
PBKDF22000⚠️ Chấp nhận đượcStandard NIST/FIPS, dùng khi yêu cầu compliance — set 600.000 iterations
Bcrypt1999✅ TốtBattle-tested, library mọi ngôn ngữ — set cost ≥ 12
scrypt2009✅ TốtChống GPU tốt hơn bcrypt — better-auth dùng mặc định
Argon2id2015✅ TỐT NHẤTOWASP 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:

  1. hash lưu chứa salt + cost + algorithm — không cần lưu riêng
  2. bcrypt.compare tự dùng đúng salt, cost — bạn không cần biết
  3. 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:

  1. User bấm "Quên mật khẩu" → nhập email
  2. Server tạo reset token ngẫu nhiên (chuỗi 32+ ký tự), lưu vào bảng password_resets với expires_at 1-24 giờ
  3. Server gửi email chứa link: https://example.com/reset?token=abc123xyz...
  4. User click link → trang reset password → nhập password mới × 2
  5. Server verify token chưa expire → hash password mới → update users.password_hash
  6. 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ớpBảo vệ gìNếu thiếu thì sao
HTTPSTruyền password qua mạngWiFi công cộng sniff được plaintext
Password hash (Bcrypt 12+)Lưu password trong DBDB leak → toàn bộ password lộ
Password policy (≥ 8 ký tự + complex)User chọn password yếu123456 hash 100 lần vẫn crack được
Rate limit login (5 lần/phút)Brute force onlineHacker thử 1 tỷ password qua API
2FA (TOTP, SMS, FIDO key)Hash bị crackHacker có password nhưng không có phone
Anomaly detection (đăng nhập IP lạ)Account takeoverUser 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ânCách fix
Login chậm 1-3 giâyCost 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ếtLưu plaintext hoặc MD5 không saltMigrate sang Bcrypt — re-hash khi user login lại
Hacker brute force qua login APIKhông rate limitThêm rate-limit 5/phút/IP (đã làm trong vietcodex audit)
Reset token bị guessDùng Math.random() thay crypto.randomBytesDùng crypto.randomBytes(32).toString('hex')
Email reset bị forward → ai cũng dùng đượcToken không có expirySet expires_at = NOW + 1 giờ, check trước khi accept
Password policy quá lỏngUser chọn 123456 đậm chất Việt NamBắ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

Câu hỏi thường gặp

Hash và Encrypt khác nhau như thế nào?
Encrypt là 2 chiều — có chìa khoá thì giải mã ra plaintext (dùng cho data cần đọc lại: email, số điện thoại, file đính kèm). Hash là 1 chiều — KHÔNG có chìa khoá nào giải ngược (dùng cho password, integrity check). Vì sao password phải hash chứ không encrypt? Vì nếu encrypt: server phải lưu chìa khoá ở đâu đó, lộ chìa = lộ toàn bộ password. Hash thì kể cả admin DB cũng không decode được, hệ thống tự nhiên bảo mật hơn.
Tại sao MD5 và SHA-1 không đủ để hash password?
Vì MD5/SHA-1 quá nhanh — máy tính gaming RTX 4090 tính được 50 tỷ hash MD5/giây. Hacker có database 'rainbow table' chứa sẵn hash của 10 tỷ password phổ biến → match ngược trong vài giây. Bcrypt/Argon2 chậm chủ ý (100ms/lần) → cùng 50 tỷ password test phải mất hàng tháng. 'Chậm là tốt' đối với password hash — đó là điểm phản trực giác lớn nhất với non-tech.
Salt là gì? Tại sao 2 user có cùng password thì hash khác nhau?
Salt là chuỗi ngẫu nhiên server tạo riêng cho từng user, ghép vào password TRƯỚC khi hash. Ví dụ user A password 'matkhau123' + salt 'xyz789' → hash X. User B cùng password 'matkhau123' + salt 'abc456' → hash Y (hoàn toàn khác). Lợi ích: (1) hacker leak DB không thể biết 2 user dùng cùng password; (2) rainbow table vô dụng vì mỗi salt cần build table riêng. Bcrypt/Argon2 tự generate salt và lưu chung trong chuỗi hash output — bạn không cần lo lưu salt riêng.
Bcrypt, Argon2, scrypt — nên chọn cái nào năm 2026?
Argon2 (cụ thể Argon2id) là khuyến nghị của OWASP từ 2021 — thắng giải Password Hashing Competition 2015, chống GPU attack tốt nhất. Bcrypt vẫn an toàn nếu cost ≥ 12 — phổ biến nhất, library tested kỹ trên mọi ngôn ngữ. Scrypt ít hỗ trợ hơn. Quy tắc: project mới → Argon2id. Project cũ đang dùng Bcrypt → giữ, đừng migrate vô lý. KHÔNG bao giờ tự code hash function — luôn dùng library (`bcrypt`, `@node-rs/argon2`). better-auth (vietcodex.com dùng) mặc định scrypt với params an toàn.
Web nào 'gửi lại mật khẩu cũ' qua email là dấu hiệu gì?
Dấu hiệu CỰC XẤU — web đó đang lưu password plaintext hoặc encrypt 2 chiều. Nếu DB của họ bị hack (chuyện thường ngày 2023-2026): toàn bộ password user bị lộ. Bạn nên: (1) đổi password ngay; (2) đổi password ở mọi web khác bạn dùng cùng password; (3) bật 2FA; (4) report về data protection authority. Web chuẩn thì 'Quên password' phải gửi link reset có token expire 1-24 giờ, KHÔNG bao giờ gửi password cũ.
Bcrypt cost factor là gì? Set bao nhiêu thì đủ?
Cost factor (work factor) là số lần lặp thuật toán — cost N nghĩa 2^N vòng. Cost 10 = 1024 vòng, cost 12 = 4096 vòng. Cao hơn = chậm hơn = an toàn hơn nhưng tốn CPU server. Quy tắc 2026: cost 12 nếu CPU server tốt (Intel Xeon, AMD EPYC), cost 10 nếu VPS yếu, cost 14 cho ngân hàng. Mục tiêu: 1 lần hash ≈ 250-500ms. Cao hơn user complain login chậm, thấp hơn không chống được GPU attack. Re-evaluate cost mỗi 2 năm theo định luật Moore.
Hash password rồi vẫn cần HTTPS không?
Có — bắt buộc. Hash chỉ bảo vệ password khi LƯU trong DB. Khi user submit form login, password vẫn ở dạng plaintext truyền từ browser tới server. Không HTTPS = ai sniff WiFi cũng đọc được password plaintext kia. Sau khi server nhận → mới hash để so với DB. Workflow đầy đủ: HTTPS bảo vệ lúc TRUYỀN, hash bảo vệ lúc LƯU. Thiếu một trong hai là lỗ to. Đó là lý do bài này nên đọc cùng [HTTPS, SSL, TLS](/wiki/co-ban/https-ssl-tls).