Password hashing sai = DB leak thành rainbow table attack. Bài này code đúng cách Node.js: bcrypt, Argon2, migration, timing-safe comparison.
Vì sao không SHA-256?
SHA-256 thiết kế để nhanh — GPU crack 1 tỷ hash/giây. Password 8 char common → crack trong giờ. bcrypt/Argon2 thiết kế CHẬM CHỦ ĐỊNH — và memory-hard (Argon2) chống ASIC.
bcrypt — proven 30 năm
npm install bcrypt
import bcrypt from 'bcrypt'
const SALT_ROUNDS = 12 // 2^12 iterations, ~250ms
// Hash khi đăng ký
const hash = await bcrypt.hash(password, SALT_ROUNDS)
// hash = "$2b$12$N9qo8uLOickgx2ZMRZoMye..." (60 chars, có salt + cost)
await db.users.create({ email, password_hash: hash })
// Verify khi login
const user = await db.users.findByEmail(email)
const ok = await bcrypt.compare(password, user.password_hash)
if (!ok) return res.status(401).end()
Benchmark cost factor:
| Cost | Time | Khi dùng |
|---|---|---|
| 10 | ~75ms | Mobile, low-end server |
| 12 | ~300ms | Default 2026 |
| 14 | ~1.2s | High-security app |
Argon2 — recommended cho mới
npm install argon2
import argon2 from 'argon2'
const hash = await argon2.hash(password, {
type: argon2.argon2id, // chống GPU + side-channel
memoryCost: 65536, // 64MB
timeCost: 3, // iterations
parallelism: 4,
})
const ok = await argon2.verify(hash, password)
Argon2id memory-hard → ASIC/GPU không có lợi thế lớn. Win password hashing competition 2015. NIST khuyến cáo cho project mới.
Pepper — defense layer
// Secret từ env, KHÔNG lưu DB
const PEPPER = process.env.PASSWORD_PEPPER // 32+ char random
function pepperedPassword(password) {
return crypto.createHmac('sha256', PEPPER).update(password).digest('hex')
}
// Hash
const hash = await argon2.hash(pepperedPassword(password))
// Verify
const ok = await argon2.verify(hash, pepperedPassword(password))
DB leak: attacker có hash + salt nhưng không có pepper → crack cực khó. Trade-off: rotate pepper khó (toàn user phải reset).
Timing-safe comparison
bcrypt.compare và argon2.verify đã timing-safe. Đừng tự code === — leak thông tin qua thời gian.
// ❌ Sai
if (storedHash === computedHash) // timing leak
// ✓ Đúng
import { timingSafeEqual } from 'node:crypto'
if (timingSafeEqual(Buffer.from(stored), Buffer.from(computed)))
Migration từ MD5/SHA1
async function login(email, password) {
const user = await db.users.findByEmail(email)
if (!user) return null
// Detect hash type
if (user.password_hash.startsWith('$argon2') || user.password_hash.startsWith('$2b$')) {
// bcrypt/Argon2 — verify
return await argon2.verify(user.password_hash, password) ? user : null
}
// Legacy MD5/SHA1
const legacyHash = crypto.createHash('md5').update(password).digest('hex')
if (legacyHash !== user.password_hash) return null
// Login OK with legacy → upgrade hash
const newHash = await argon2.hash(password)
await db.users.update(user.id, { password_hash: newHash })
return user
}
User không biết đang được migrate. Sau 6-12 tháng, force password reset user chưa login.
Checklist password storage
- ✅ Argon2id (mới) hoặc bcrypt cost ≥12
- ✅ KHÔNG SHA-256, MD5, SHA-1
- ✅ Pepper từ env cho high-value app
- ✅ Min length password 12+ char (NIST 2024)
- ✅ Block password trong rainbow list (haveibeenpwned API)
- ✅ Rate limit /login để chống brute-force
- ✅ MFA cho admin/payment
Kết luận
Password hashing đúng = Argon2id hoặc bcrypt cost 12. Combine pepper + rate limit + MFA cho defense-in-depth. Đừng tự code crypto. Đọc JWT authentication để hiểu phần auth còn lại của hệ thống.