口令存储为什么要加盐和慢哈希
这篇文章写一个和密码实验直接相关、也很常见的安全问题:用户口令到底应该怎么存。
很多初学者会觉得:
password -> SHA256 -> 存数据库 |
这样已经不是明文,应该安全了。但在真实系统里,这还不够。原因是普通哈希太快、结果确定,一旦数据库泄露,攻击者可以离线高速尝试常见口令。
更合理的方向是:
password + random salt -> password hashing / KDF -> 存算法、参数、salt、hash |
本文用 Python 标准库中的 PBKDF2-HMAC-SHA256 演示。真实新系统更推荐 Argon2id、scrypt、bcrypt 这类专门口令哈希算法;这里用 PBKDF2 是因为它不需要额外依赖,便于复现。
1. 普通 SHA256 的问题
普通哈希有一个特点:
同一个输入永远得到同一个输出 |
例如:
import hashlib |
对同一个口令计算两次:
Correct-Horse-Battery-Staple |
结果完全一样。
真实运行截图:

截图里可以看到:
SHA256(password): a8936e06dff4325a005bc4b68d5775bff9b3b05122784af264137aa6cd433ff3 |
这带来两个问题:
两个用户使用同一口令,数据库中的 hash 相同 |
2. salt 是什么
salt 是每条口令记录单独生成的一段随机数据。
salt = random bytes |
salt 不需要保密,可以和 hash 一起存储。它的作用不是当作第二个密码,而是让相同口令在不同记录中产生不同结果。
例如两个用户都使用:
Correct-Horse-Battery-Staple |
如果 salt 不同,存储记录也不同:
Record 1: pbkdf2_sha256$600000$...$... |
这能防止攻击者通过“两个 hash 一样”直接看出两个用户口令一样,也能让预计算表失效。
3. 为什么还需要慢哈希
salt 解决的是相同口令、预计算表的问题,但不解决普通哈希太快的问题。
如果攻击者拿到数据库,可以离线尝试:
candidate password -> hash -> compare |
普通 SHA256 非常快,这对文件校验很好,但对口令存储不合适。
慢哈希/KDF 会故意提高每次尝试成本。例如 PBKDF2 会重复迭代很多轮:
PBKDF2-HMAC-SHA256(password, salt, iterations=600000) |
这样用户登录时多花一点点时间,但攻击者批量猜测时成本会显著上升。
4. 存储记录应该包含什么
数据库里不要只存一个 hash。至少要存:
algorithm$iterations$base64(salt)$base64(hash) |
例如:
pbkdf2_sha256$600000$base64_salt$base64_hash |
为什么要存算法和参数?
以后可以升级迭代次数 |
5. Python 实现
派生 PBKDF2 hash:
import base64 |
编码存储记录:
def encode_record(iterations: int, salt: bytes, digest: bytes) -> str: |
验证口令:
def verify_password(password: str, record: str) -> bool: |
这里使用:
hmac.compare_digest(actual, expected) |
而不是普通 ==。它用于常量时间比较,避免比较过程泄露细微时间差。
6. 生成两条记录
演示代码中,每次都使用:
salt = os.urandom(16) |
生成随机 salt。
password = "Correct-Horse-Battery-Staple" |
真实运行截图里可以看到:
Record 1: pbkdf2_sha256$600000$...$... |
两条记录不同,但都能用正确口令验证通过。
7. VSCode 代码截图
下面是完整脚本在 VSCode 中运行的截图:

脚本文件路径:
D:\密码实验\work\blog_password_storage\password_storage_demo.py |
8. Argon2id、scrypt、bcrypt、PBKDF2 怎么选
常见选择可以这样理解:
| 算法 | 特点 | 建议 |
|---|---|---|
| Argon2id | 可设置时间成本、内存成本、并行度 | 新系统优先考虑 |
| scrypt | 内存硬算法,增加硬件批量攻击成本 | 适合口令存储 |
| bcrypt | 历史长,库支持广 | 注意输入长度限制 |
| PBKDF2 | 标准化时间久,合规和标准库常见 | 迭代次数要足够高 |
本文使用 PBKDF2 是教学演示,不是说它永远最优。真实项目应结合语言生态、合规要求和运行成本选择。
9. 常见错误
9.1 salt 写死
错误:
salt = "my-fixed-salt" |
salt 必须每条记录随机生成。固定 salt 无法让相同口令产生不同记录。
9.2 把 salt 当秘密
salt 可以公开存储。它的作用是唯一性,不是保密性。
如果需要服务器端秘密,可以讨论 pepper。但 pepper 不应和数据库记录放在一起。
9.3 迭代次数永远不升级
硬件会变快,旧参数会逐渐不够。比较实际的做法:
记录中保存 iterations |
9.4 忘记登录限速
慢哈希主要提高离线攻击成本。在线登录接口仍然需要:
失败次数限制 |
9.5 使用裸 MD5/SHA1/SHA256 存密码
这些是通用哈希函数,不是口令存储方案。即使用 SHA256,也缺少 salt 和成本参数。
10. 小结
口令存储至少应做到:
每条记录随机 salt |
不要把:
SHA256(password) |
当成完整口令存储方案。它在真实泄露场景下很脆弱。
参考资料
- OWASP Password Storage Cheat Sheet
- NIST SP 800-63B, Digital Identity Guidelines
- RFC 9106: Argon2 Memory-Hard Function
- Python hashlib 文档: https://docs.python.org/3/library/hashlib.html