
检查清单先行
在解释之前,以下是正确存储密码必须做的事情。每一项都很重要。
- 绝不存储明文密码
- 绝不单独使用 MD5、SHA-1 或 SHA-256 处理密码
- 使用 bcrypt、Argon2id 或 scrypt——而非通用哈希
- 让库自动生成盐——不要手动操作
- 设置工作因子,使哈希计算在你的服务器上耗时 100–300 毫秒
- 强制密码最小长度为 8 个字符,最大长度为 72 个字符(bcrypt 限制)
- 在服务器端哈希密码,而非客户端
- 绝不记录密码,即使在错误消息中
- 在登录端点实施速率限制和账户锁定

为什么 MD5 和 SHA-256 不适合密码
这是密码安全中最容易被误解的一点。MD5、SHA-1 和 SHA-256 很快。非常快。现代 GPU 每秒可以计算 100 亿次 MD5 哈希。这意味着破解者可以在大约 30 秒内针对 MD5 哈希数据库测试所有 8 位小写密码。
密码哈希算法如 bcrypt、Argon2 和 scrypt 故意设计得很慢。它们可调优,你可以让哈希计算恰好花费服务器能容忍的时间(通常 100–300 毫秒)。同样的 30 秒破解将变成 3000 年。
原始 SHA-256 的另一个问题:没有盐。如果两个用户密码相同,他们的哈希值相同。攻击者可以通过在彩虹表中找到一个匹配的哈希值,一次性破解数千个账户。
正确的算法
bcrypt
支持最广泛的选择。在每种主流语言中都可使用。盐是内置的——bcrypt 自动生成盐并将其存储在哈希字符串中。
工作因子(rounds)是 2 的幂:rounds=12 表示 2¹² = 4096 次迭代。随着硬件性能提升而增加。行业标准起点是 10–12。
// Node.js with bcryptjs
const bcrypt = require('bcryptjs');
// 注册时哈希
const hash = await bcrypt.hash(plainPassword, 12); // 12 rounds
// 将 'hash' 存入数据库
// 登录时验证
const isMatch = await bcrypt.compare(plainPassword, storedHash);
// 返回 true/false——内置了时序安全比较
# Python with bcrypt
import bcrypt
# 注册时哈希
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode(), salt)
# 将 'hashed'(字节)存入数据库
# 登录时验证
is_match = bcrypt.checkpw(password.encode(), stored_hash)
Bcrypt 限制: 最大输入为 72 字节。超过 72 个字符的密码会被静默截断。如果你想支持更长的密码,先用 SHA-256 预哈希并 base64 编码,再传给 bcrypt:
const crypto = require('crypto');
const prehash = crypto.createHash('sha256').update(password).digest('base64');
const hash = await bcrypt.hash(prehash, 12);

Argon2id
2015 年密码哈希竞赛的获胜者。比 bcrypt 更可配置:你可以分别调整内存使用量(RAM)、并行度(CPU 线程)和迭代次数。Argon2id 是混合变体——既能抵抗 GPU 攻击,也能抵抗侧信道攻击。
// Node.js with argon2
const argon2 = require('argon2');
const hash = await argon2.hash(password, {
type: argon2.argon2id,
memoryCost: 65536, // 64 MB
timeCost: 3, // 3 次迭代
parallelism: 4, // 4 个线程
});
const isMatch = await argon2.verify(hash, password);
# Python with argon2-cffi
from argon2 import PasswordHasher
ph = PasswordHasher(time_cost=3, memory_cost=65536, parallelism=4)
hash = ph.hash(password)
try:
ph.verify(hash, password)
# 登录成功
if ph.check_needs_rehash(hash):
new_hash = ph.hash(password)
# 用 new_hash 更新数据库
except Exception:
pass # 登录失败
scrypt
自 Python 3.6 起内置于标准库,Node.js 的 crypto 模块也有。如果你无法添加外部依赖,这是个不错的选择。
// Node.js built-in crypto
const crypto = require('crypto');
const util = require('util');
const scrypt = util.promisify(crypto.scrypt);
const salt = crypto.randomBytes(32);
const hash = await scrypt(password, salt, 64);
// 存储:salt.toString('hex') + ':' + hash.toString('hex')
算法对比
| 算法 | 内置盐 | GPU 抵抗性 | 内存硬度 | 推荐? |
|---|---|---|---|---|
| MD5 | 否 | 非常弱 | 否 | ❌ 绝不 |
| SHA-256 | 否 | 非常弱 | 否 | ❌ 绝不 |
| bcrypt | 是 | 中等 | 否 | ✅ 是 |
| scrypt | 手动 | 强 | 是 | ✅ 是 |
| Argon2id | 是 | 强 | 是 | ✅ 最佳选择 |
| PBKDF2 | 手动 | 中等 | 否 | ✅ 如果其他不可用 |

设置正确的工作因子
目标:在您的生产服务器上,哈希一个密码应耗时 100–300 毫秒。太快 = 容易破解。太慢 = 攻击者通过垃圾登录端点造成 DoS 风险。
// 基准测试脚本——在您的生产硬件上运行
const bcrypt = require('bcryptjs');
for (let rounds = 10; rounds <= 14; rounds++) {
const start = Date.now();
await bcrypt.hash('benchmark', rounds);
console.log(`rounds=${rounds}: ${Date.now() - start}ms`);
}
// 选择低于 300ms 的最高 rounds 值
现代服务器上的典型结果:
- rounds=10: ~65ms
- rounds=12: ~250ms ← 良好的默认值
- rounds=14: ~1000ms ← 对大多数应用来说太慢
数据库应存储的内容
对于 bcrypt,整个哈希字符串包含算法、版本、轮数、盐和哈希——全部在一个字符串中,例如:
$2b$12$LrhasAakElf4YCRZzEJxXONMpBRrAVbKCMSJIJSDJSJDHEIJJ
将此整个字符串存储在一个 VARCHAR(255) 或 TEXT 列中。切勿单独存储盐。切勿在单独的列中存储任何关于算法的元数据——它已经在哈希字符串中了。
检查现有密码存储
如果您正在审计现有应用程序:
-- PostgreSQL:查找未哈希的密码(明文少于 50 字符,无哈希前缀)
SELECT COUNT(*) FROM users WHERE LENGTH(password_hash) < 20;
-- 查找 MD5 哈希(32 个十六进制字符)
SELECT COUNT(*) FROM users WHERE password_hash ~ '^[a-f0-9]{32}#39;;
-- 查找 SHA-256 哈希(64 个十六进制字符)
SELECT COUNT(*) FROM users WHERE password_hash ~ '^[a-f0-9]{64}#39;;
-- 查找 bcrypt 哈希(正确)
SELECT COUNT(*) FROM users WHERE password_hash LIKE '$2%';
→ 在浏览器中使用 Bcrypt 工具 测试 bcrypt 哈希生成和验证——有助于验证您的库生成的内容。