「用户的密码,到底该怎么存进数据库才对?」——这是每个做服务的人都必然会撞上一次的问题。答案很明确。我们不涉及攻击手法,只按顺序讲清楚安全的存储方法。
为什么明文、加密、裸哈希都不行
我们按数据库迟早会泄露这个前提来考虑。一旦泄露,不同的存储方式造成的损失天差地别。
- 明文:泄露的瞬间所有密码一览无余。再加上重复使用同一密码,用户在其他服务上的账号也会被连锁攻陷。最糟。
- 加密(可逆):有密钥就能还原,所以密钥一起泄露就等同于明文。密码本就没有读回来的必要,因此可逆这件事本身毫无意义。
- 裸哈希(MD5/SHA-256):快速哈希让攻击者能高速尝试海量候选。常见密码会被彩虹表或暴力破解还原出来。
达到安全存储的四个阶段
把它理解成给弱方式一步步叠加对策的过程,会很快上手。
要点在于,盐和慢哈希的职责是不同的。盐负责让「预计算(彩虹表)和批量破解重复密码」失效。慢哈希负责把「暴力破解的速度压到不现实的水平」。两者齐备,才第一次称得上安全(→ 什么是哈希化)。
实践:现在就该做的事
使用 Argon2id(或 bcrypt)
新项目把 Argon2id 作为首选。它还能要求消耗内存,因此对 GPU 暴力破解很有抵抗力。如果更看重成熟稳定的实现,bcrypt 在实践中也完全够用。两者都直接使用标准库的实现。
让库来生成盐
bcrypt/Argon2 会为每个用户自动生成盐,并把它一并塞进哈希字符串里。你不需要自己另存一列盐。手动去拼 MD5(salt + password),正是不该犯的典型错误。
按运行环境设置成本(强度)
把 bcrypt 的成本系数、Argon2 的内存・迭代・并行度,设到正常登录在体感上察觉不到延迟的范围内的最大值。服务器越快,攻击者也越快,所以要以年为周期复核并上调。
校验用标准的比对函数
登录时,把已存哈希与输入用库提供的比对函数进行匹配(很多实现已经做成了对时序差有所防范的常数时间比较)。不要自己写字符串相等判断。
弱哈希用“登录时重新哈希”来迁移
如果已经用 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 | 新系统的首选 |
| scrypt | N=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)做到位,以及未来向通行密钥(无密码)迁移。再强的哈希,也兜不住弱密码和重复使用。
延伸阅读
- 术语:什么是密码哈希化 / 什么是盐
- 入门:密码的正确保管(用户侧) / 如何挑选密码管理器
- 对策:如何挑选两步验证(MFA)(减少对密码的依赖)
- 术语:什么是通行密钥(向无密码迁移)
- 案例:宅ふぁいる便(Takufile-bin)信息泄露事件(2019)(明文保存密码的真实案例)
出处
- OWASP「Password Storage Cheat Sheet」:cheatsheetseries.owasp.org
- NIST「SP 800-63B-4」(2025 年 8 月),密码存储(盐与哈希):pages.nist.gov
- RFC 9106「Argon2 Memory-Hard Function for Password Hashing and Proof-of-Work Applications」:rfc-editor.org
- PHP 手册「password_hash」:php.net
- Django 文档「Password management in Django」(5.2):docs.djangoproject.com