"为什么不能用 MD5 存密码?""bcrypt、scrypt、argon2 选哪个?""salt 是干嘛的?" —— 密码哈希是 Web 应用最常被做错的安全设计。这篇文章把密码哈希的原理、攻击手段、现代算法选型一次讲透。所有结论都配可运行代码。
为什么不能用 MD5 / SHA1 / SHA256
它们是通用哈希算法,设计目标是"快" —— 现代 GPU 一秒能算几十亿次 MD5、几亿次 SHA256。这恰恰是"不该用来存密码"的原因 —— 攻击者拖库后用 GPU 跑彩虹表 / 字典攻击,几小时能破解海量弱密码。
# 一台 RTX 4090 大致速度
MD5: ~65 GH/s (650 亿次/秒)
SHA1: ~24 GH/s
SHA256: ~9 GH/s
bcrypt(cost=10): ~150 KH/s (15 万次/秒,相差 6 个数量级)
argon2id: ~1 KH/s (1 千次/秒)
密码哈希要的是"慢"和"资源消耗大",让大规模暴力破解不可行。
密码哈希的三个核心要求
- 单向:从 hash 反推 password 几乎不可能。
- 慢:每次哈希 100ms 量级,正常登录无感,暴力破解极慢。
- 有 salt:每个用户的 hash 都不同,即使两个用户密码一样,hash 也不同 —— 防御彩虹表。
bcrypt:经典选择
1999 年提出,基于 Blowfish。核心特性:
- 内置 salt,无需自己加。
- 有 cost 参数(2^cost 次迭代),可调难度。
- 每个 hash 长度固定 60 字符。
# Python
import bcrypt
# 注册时
password = b"my_secret_password"
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
# 存数据库:$2b$12$EixZaYVK1fsbw1ZfbX3OXePaWxn96p36WQoeG6Lruj3vjPGga31lW
# 登录时验证
if bcrypt.checkpw(password, hashed):
print("密码正确")
cost(rounds)选 10-14 是常用范围。每加 1,时间翻倍。2025 年推荐起步 12,登录耗时约 200ms,正常用户无感,暴力破解几乎不可能。
scrypt:抗 ASIC 攻击
2009 年提出,强制需要大量内存(GPU / ASIC 难以并行)。专业暴力破解集群上 scrypt 比 bcrypt 慢 10-100 倍。
import hashlib
# 内置 hashlib
salt = os.urandom(16)
hashed = hashlib.scrypt(password, salt=salt, n=16384, r=8, p=1, dklen=64)
# 存:salt + hashed 一起存
# n:CPU 成本(2^14 = 16384)
# r:内存成本
# p:并行因子
Argon2:2015 年起的赢家
Password Hashing Competition(密码哈希竞赛)2015 年的冠军。现在公认的最好选择。三个变种:
- Argon2i:抗侧信道攻击,但抗 GPU 弱一些。
- Argon2d:抗 GPU 强,但有侧信道风险。
- Argon2id(推荐):混合两者的优点。
import argon2
ph = argon2.PasswordHasher(
time_cost=3, # 迭代次数
memory_cost=65536, # 内存 64 MB
parallelism=4, # 4 个并发线程
)
# 哈希
hashed = ph.hash(password)
# 验证
try:
ph.verify(hashed, password)
print("OK")
except argon2.exceptions.VerifyMismatchError:
print("密码错")
OWASP 2025 推荐配置:argon2id 至少 19 MiB 内存 + 2 次迭代 + 1 并行,或更高。具体数值要根据服务器性能调,目标是"每次哈希约 100-200ms"。
选型决策
- 新项目:直接 argon2id。所有现代语言都有库。
- 已有 bcrypt 系统:继续 bcrypt(cost ≥ 12),够用。
- 没有 argon2 库:bcrypt 或 PBKDF2-HMAC-SHA256(NIST 标准,迭代次数至少 600,000)。
- 永远不要:MD5、SHA1、SHA256、SHA512 直接哈希密码。
密码升级机制
老系统用 bcrypt(cost=10),要升级到 cost=12,怎么办?
def login(username, password):
user = db.find_user(username)
if not bcrypt.checkpw(password, user.password_hash):
return error("密码错误")
# 检查 hash 是否需要升级
# bcrypt hash 前缀含 cost 信息
current_cost = extract_cost(user.password_hash)
if current_cost < 12:
# 用户刚登录,我们手上有明文密码 - 顺便升级 hash
new_hash = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
db.update_user_hash(user.id, new_hash)
return success(user)
这种"登录时静默升级"机制让你能持续提升安全水平,无需逼用户改密码。
"密码不要明文传输"
TLS,在 HTTP 之上加一层 TLS 加密,防止中间人窃听和篡改。">HTTPS 加密传输不是可选,是必须。但有些团队还会问:"能不能在前端 hash 一下再传?"
答:这不能替代 HTTPS,但可以作为补充。前端 hash 后,服务端收到的"密码"实际是 hash 值 —— 服务端再做一次密码哈希。好处:抓包看不到明文(防止运维 / 安全审计看到用户密码);坏处:服务端 hash 算法逻辑要兼顾。一般不做这一层,关键是 HTTPS。
登录次数限制 + 二步验证
再强的密码哈希也防不住"知道你密码"的攻击者(网络钓鱼、撞库)。配合:
- 限流:单 IP / 单账号失败次数限制。
- 异常登录告警:陌生地点 / 设备登录通知用户。
- 2FA:TOTP(Google Authenticator)、SMS、推送通知、硬件 key。
2FA 是性价比最高的安全升级 —— 即使密码泄漏,没有第二因素也登不进。重要业务(管理员、金融)必须强制。
密码策略
NIST SP 800-63B 现代密码指南:
- 最小长度 8(推荐 12),最大长度 ≥ 64,允许空格和所有 Unicode。
- 不要要求"必须含大写 + 数字 + 特殊字符" —— 反而让用户用 "Password1!" 这种弱密码。
- 不要定期强制改密码 —— 让用户改成 "Password1!", "Password2!" ...
- 要对照"常见泄漏密码库"(haveibeenpwned API)拒绝弱密码。
# Have I Been Pwned API 用 k-anonymity:
# 只传密码 sha1 的前 5 位,服务器返回所有前 5 位匹配的尾部 + 计数
import hashlib, requests
def check_pwned(password):
sha1 = hashlib.sha1(password.encode()).hexdigest().upper()
prefix, suffix = sha1[:5], sha1[5:]
r = requests.get(f"https://api.pwnedpasswords.com/range/{prefix}")
for line in r.text.splitlines():
line_suffix, count = line.split(':')
if line_suffix == suffix:
return int(count)
return 0
count = check_pwned("password123")
if count > 0:
raise Error(f"这个密码已被泄漏 {count} 次,请换一个")
常见坑
坑 1:salt 写死。 全局一个 salt = 没 salt,所有用户的彩虹表能复用。每个用户一个独立 salt(bcrypt / argon2 内置)。
坑 2:把 hash 当用户标识。 用户改密码后 hash 变了,session / refresh_token 是不是也要重建?要 —— 否则旧 token 还能用。改密码时主动清掉所有 session。
坑 3:在日志 / 错误信息里泄漏 hash。 hash 也是敏感数据 —— 泄漏后攻击者能离线暴力。日志脱敏要包含 password 字段(且不要打 hash 本身)。
坑 4:cost 参数过低。 服务器升级后不调 cost,慢慢就变弱。规则:每年 review 一次,根据当前硬件提升 cost。
哈希算法的内部机制
bcrypt / argon2 之所以慢,不只是"多次迭代"。它们设计了专门的内存访问模式让 GPU / ASIC 难以加速。
bcrypt 用 Blowfish 算法的密钥扩展,每次都重新初始化 4 个 1KB 的 S-box —— 这是它的"小内存敏感"特性。但 4KB 对现代 GPU 不算什么,所以 bcrypt 的抗 GPU 能力不如 argon2。
argon2 把内存需求拉到几十 MB,且访问模式高度依赖前面的结果(无法并行预测)—— 这让 GPU 的并行优势完全失效。这是现代密码哈希算法的核心创新。
安全存储与日志
密码相关的隐私规则:
- 日志绝不打印密码 —— 包括登录失败时的 debug 日志。
- 错误信息保持模糊:登录失败统一返回"用户名或密码错",不要区分"用户名不存在"和"密码错" —— 后者会泄漏哪些账号存在。
- 密码字段不出现在响应:返回用户信息时把 password / password_hash 字段 strip 掉。
- 不要发送密码到邮箱:即使是重置流程,也只发"临时链接",不是密码本身。
密码学相关工具
# 生成强随机密码
openssl rand -base64 24
python -c "import secrets; print(secrets.token_urlsafe(24))"
# 用 hashcat 测试密码强度(攻击者视角)
hashcat -m 3200 hashes.txt wordlist.txt # 3200 = bcrypt
hashcat -m 14000 hashes.txt wordlist.txt # 14000 = argon2
# 测试自己服务上 hash 一次需要多久(目标 100-200ms)
time python -c "import bcrypt; bcrypt.hashpw(b'test', bcrypt.gensalt(12))"
写在最后
密码哈希看似简单但极易出错。规则也简单:用 argon2id,实在没有用 bcrypt cost >= 12,绝不用 MD5 / SHA 直接哈希。配合 HTTPS + 限流 + 2FA + 拒绝弱密码,你的认证系统就能扛住绝大多数攻击。这是 Web 工程师必须掌握的基础安全知识 —— 错了就是给攻击者送大礼。
一图看懂
密码哈希流程一图看懂:
—— 别看了 · 2026