安全指南

如何安全存储密码:哈希与加盐的正确做法

用户密码该如何存进数据库?面向开发者说明明文和裸哈希为何不行、为何要为每个用户加盐,以及慢哈希(Argon2id/bcrypt/scrypt)的选型、成本参数与旧哈希的迁移方法。

发布于 2026-06-27 更新于 2026-10-06 3 分钟阅读
本页目录

「用户的密码,到底该怎么存进数据库才对?」——这是每个做服务的人都必然会撞上一次的问题。答案很明确。我们不涉及攻击手法,只按顺序讲清楚安全的存储方法。

为什么明文、加密、裸哈希都不行

我们按数据库迟早会泄露这个前提来考虑。一旦泄露,不同的存储方式造成的损失天差地别。

  • 明文:泄露的瞬间所有密码一览无余。再加上重复使用同一密码,用户在其他服务上的账号也会被连锁攻陷。最糟。
  • 加密(可逆):有密钥就能还原,所以密钥一起泄露就等同于明文。密码本就没有读回来的必要,因此可逆这件事本身毫无意义。
  • 裸哈希(MD5/SHA-256):快速哈希让攻击者能高速尝试海量候选。常见密码会被彩虹表或暴力破解还原出来。

达到安全存储的四个阶段

把它理解成给弱方式一步步叠加对策的过程,会很快上手。

① 明文:一泄露所有密码都能被读取
↓ 改为单向
② 裸哈希:扛不住彩虹表
↓ 为每个用户加盐
③ 加盐哈希:彩虹表失效,但暴力破解依旧很快
↓ 故意放慢
④ 加盐+慢哈希(Argon2id / bcrypt):这才是正解
明文 → 裸哈希 → 加盐 → 慢哈希。只有走到最后一步,才真正算得上『安全的存储』。

要点在于,盐和慢哈希的职责是不同的。盐负责让「预计算(彩虹表)和批量破解重复密码」失效。慢哈希负责把「暴力破解的速度压到不现实的水平」。两者齐备,才第一次称得上安全(→ 什么是哈希化)。

实践:现在就该做的事

1

使用 Argon2id(或 bcrypt)

新项目把 Argon2id 作为首选。它还能要求消耗内存,因此对 GPU 暴力破解很有抵抗力。如果更看重成熟稳定的实现,bcrypt 在实践中也完全够用。两者都直接使用标准库的实现。

2

让库来生成盐

bcrypt/Argon2 会为每个用户自动生成盐,并把它一并塞进哈希字符串里。你不需要自己另存一列盐。手动去拼 MD5(salt + password),正是不该犯的典型错误。

3

按运行环境设置成本(强度)

把 bcrypt 的成本系数、Argon2 的内存・迭代・并行度,设到正常登录在体感上察觉不到延迟的范围内的最大值。服务器越快,攻击者也越快,所以要以年为周期复核并上调。

4

校验用标准的比对函数

登录时,把已存哈希与输入用库提供的比对函数进行匹配(很多实现已经做成了对时序差有所防范的常数时间比较)。不要自己写字符串相等判断。

5

弱哈希用“登录时重新哈希”来迁移

如果已经用 MD5/SHA-256 存了,就在用户登录成功的那一刻用新方式重新哈希并覆盖保存。对于尚未登录的用户,可以在过渡期间用 bcrypt 等对既有哈希再做一次哈希(双重哈希)来提升强度。

常见的错误做法 vs 正确的实现

常见错误

  • 把密码加密后存储(密钥一泄露就全完)
  • 直接存 MD5(password) 或 SHA-256(password)
  • 所有用户共用同一个盐
  • 自己手动拼 hash(salt + password)

正确的实现

  • 用 Argon2id / bcrypt 做单向哈希
  • 盐按每个用户分配(由库自动附加)
  • 成本设在体感可接受范围内的最大值,并定期上调
  • 直接使用标准的哈希/比对函数

哈希、加密与编码的区别

这三者常被混淆,但在能否还原这一点上完全不同。存储密码时,只能用哈希。

哈希加密编码(Base64 等)
能否还原不能(单向)有密钥就能任何人都能
密钥不需要需要不需要
典型用途密码存储、篡改检测让数据或通信保密转换数据格式
用于存储密码用(加盐+慢哈希)不用不用(等同于明文)

推荐设置(OWASP)

以下是 OWASP Password Storage Cheat Sheet 给出的最低设置。请把它们当作下限,再根据你的服务器适当调高。

算法最低设置何时使用
Argon2id内存 19 MiB、迭代 2 次、并行度 1新系统的首选
scryptN=2^17(128 MiB)、r=8、p=1无法使用 Argon2id 时
bcrypt成本 10 以上。输入上限 72 字节既有系统,实际使用经验丰富
PBKDF2-HMAC-SHA256迭代 600,000 次以上需要符合 FIPS 140 时

也要确认所用平台的内置功能。PHP 的 password_hash() 默认使用 bcrypt(PASSWORD_DEFAULT),PHP 8.4 把默认成本从 10 提高到了 12。Django 默认使用 PBKDF2;安装 django[argon2] 并把 Argon2 放在 PASSWORD_HASHERS 的第一位,即可切换。用户登录时,Django 会自动对用旧算法存储的密码重新哈希。

如何检查自己的服务

通常从存储的哈希字符串的开头,就能看出用的是哪种方式。请在你能控制的环境中查看几行,不要把生产数据复制到别处。

哈希的样子方式处理
$argon2id$v=19$m=…,t=…,p=…$Argon2id确认 m、t、p 达到 OWASP 的最低设置
$2y$12$… / $2b$10$…bcrypt(数字是成本)低于 10 就调高
pbkdf2_sha256$1000000$…PBKDF2(数字是迭代次数)确认至少为 600,000
32 个十六进制字符很可能是 MD5用登录时重新哈希来迁移
40 / 64 个十六进制字符很可能是 SHA-1 / SHA-256同上
登录用的字符串原样存放明文最先迁移。评估万一泄露时的影响

另外也要确认以下几点:

  • 输入的密码没有被记录到应用或 Web 服务器的日志、错误报告或分析工具中
  • 如果使用 bcrypt,已经决定好如何处理超过 72 字节的输入
  • 备份和为测试制作的数据库副本里,也只包含同样的哈希值

真实案例:明文存储扩大了损害

在宅ふぁいる便(Takufile-bin)事件(2019 年)中,服务器遭到非法访问,约 480 万条记录泄露,其中的登录密码是以明文存储的。如果当时用加盐的慢哈希存储,泄露的密码被直接拿到其他服务上使用的风险就会低得多。即使无法阻止所有入侵,密码的存储方式也会改变泄露造成的损害大小。

本站的观点:密码存储就用经过验证的标准实现

密码存储不是一个可以彰显独创性的领域。老老实实使用经过广泛验证的 Argon2/bcrypt 标准实现,既最安全,运维也最省心。本站的立场是「在这里不要搞创意发挥」。真正该投入精力的,是从根本上减少对密码的依赖——把多因素认证(MFA)做到位,以及未来向通行密钥(无密码)迁移。再强的哈希,也兜不住弱密码和重复使用。

延伸阅读

出处

FAQ

Q密码加密后再存储不就行了吗?
A

不行,加密并不合适。加密只要有密钥就能还原(解密),密钥一旦泄露,所有密码就会被还原成明文。密码对运营方来说也没有读回来的必要,因此无法还原的『哈希化』才是正解。不过裸哈希还不够,需要再配合为每个用户加盐以及慢哈希(bcrypt/Argon2)。

Qbcrypt 和 Argon2,到底该用哪个?
A

如果是新项目,Argon2(尤其是 Argon2id)是首选。因为它的内存占用和计算量都可调,能很好地抵御 GPU 暴力破解。如果更看重既有资产或成熟稳定的实现,bcrypt 在实践中也完全够用。关键在于:无论选哪个,都要『直接使用标准库的实现,绝不自己拼装』。

Q我已经用 MD5 或 SHA-256 存了密码,该怎么迁移?
A

没法把它们一次性还原成明文(这恰恰是它的优点),所以要在登录时迁移。当用户成功登录的那一刻,用新方式(如 Argon2id)对输入的正确密码重新哈希并覆盖保存。对于一直没登录的用户,可以在过渡期间用 bcrypt 等对既有哈希再做一次哈希(双重哈希)来提升强度。

Q盐(salt)和胡椒(pepper)有什么区别?
A

盐是每个用户各不相同的随机值,与哈希值一起存放在数据库里,不需要保密。胡椒是所有用户共用的一个秘密值,存放在数据库以外的地方(例如密钥管理服务或 HSM)。OWASP 把胡椒定位为只有数据库泄露时的额外防御。盐是必需的,胡椒是可选的补充。

Qbcrypt 的 72 字节限制是什么?
A

bcrypt 只使用输入的前 72 个字节,第 72 字节之后的内容会被忽略。多字节字符(中文、日文、emoji)每个大约占 3–4 个字节,所以会比想象中更早达到上限。新系统请选择没有这个限制的 Argon2id;如果使用 bcrypt,请设置输入长度的上限(OWASP)。

Q盐需要保密吗?
A

不需要。盐的作用是让相同的密码在每个用户那里得到不同的哈希值,从而让预先计算的表和批量破解失效。它不需要保密,bcrypt 和 Argon2 会把它存放在哈希字符串里。NIST SP 800-63B-4 要求盐至少为 32 位。