本文参考了《如何在数据库中安全地存储用户密码》的选题和层次,但按当前 OWASP 与 Spring Security 文档重新校准了算法选择、参数和迁移方式。
密码存储的目标不是“数据库里看不到明文”这么简单,而是:即使攻击者拿到了完整用户表和备份,也要让每一次离线猜测都足够昂贵。
因此密码通常不应被“加密后保存”。加密是可逆的,只要密钥泄露就能批量还原;密码验证并不需要取回原文,适合的是带随机盐、可调成本的单向密码哈希。
先排除四种错误方案
明文
数据库、备份、日志或只读账号任意一处泄露,所有密码都会立即暴露。这不是“内网系统可以接受”的折中,而是无法补救的设计缺陷。
可逆加密
如果应用能解密,攻击者在拿到密钥、环境变量或应用执行权限后也能解密。除非系统必须把这个密码转交给一个只接受原始密码的旧系统,否则应重新设计认证方式,而不是保存可逆密文。
MD5、SHA-1、SHA-256
这些通用摘要算法追求速度。文件校验时这是优点,密码存储时却意味着 GPU 可以更快尝试候选密码。即使加盐,快速哈希也只是阻止预计算表,不能显著提高单次猜测成本。
自己拼接多轮哈希
“SHA-256 三次 + 自定义 salt + 一段固定字符串”仍然缺少成熟方案的参数编码、内存成本、审查和升级机制。密码学中,难以读懂不等于难以破解。
当前算法怎么选
OWASP 当前的优先建议是:
- 优先使用 Argon2id,最低基线为 19 MiB 内存、2 次迭代、并行度 1;
- 无法使用 Argon2id 时选择 scrypt;
- bcrypt 更适合需要兼容的存量系统,work factor 至少为 10,并注意其 72 字节输入限制;
- 有 FIPS-140 要求时使用 PBKDF2-HMAC-SHA-256,并按当前指南设置迭代次数。
这些是起点,不是永远不变的常量。部署前应在生产同规格硬件上压测登录延迟和并发容量,再选择攻击成本与服务容量之间的平衡点。
盐、pepper 和工作因子分别解决什么
每个密码一个随机盐
盐让相同密码产生不同结果,也让攻击者无法用一份预计算结果同时匹配所有用户。成熟库通常会自动生成盐,并把盐、算法版本和成本参数一起编码进最终字符串;应用不需要再建一个“salt 字段”手工拼接。
盐不需要保密。它的价值来自唯一性,而不是秘密性。
工作因子
工作因子控制一次验证需要多少 CPU 和内存。参数应该能随硬件发展逐步提高,所以数据库中必须保留算法和参数信息,不能只存一段无法辨认版本的摘要。
pepper
pepper 是数据库之外的额外秘密,可以保存在密钥管理服务或 HSM 中,用于纵深防御。它不是 salt 的替代品,而且轮换困难:如果旧 pepper 泄露,通常无法在不知道用户原始密码的情况下直接重算,只能要求用户重置或在后续登录时迁移。
Spring Security 中的实现
Spring Security 提供 PasswordEncoder,不要自己实现盐生成、格式拼接和比较逻辑。若希望新密码使用符合 OWASP 最低内存基线的 Argon2id,可以显式配置参数:
@Bean
PasswordEncoder passwordEncoder() {
// salt 16 bytes, hash 32 bytes, parallelism 1,
// memory 19 MiB, iterations 2
return new Argon2PasswordEncoder(
16,
32,
1,
19 * 1024,
2
);
}
Spring Security 当前的 Argon2 实现需要 Bouncy Castle。参数也不能只照抄:上线前要测单次验证耗时、峰值登录 QPS 和内存占用,避免认证接口被昂贵哈希反向拖垮。
注册与登录只需要 encode 和 matches:
String stored = passwordEncoder.encode(rawPassword);
userRepository.savePasswordHash(userId, stored);
boolean passed = passwordEncoder.matches(loginPassword, stored);
不要把原始密码或编码前后的中间值写进日志,也不要先在前端做一次 MD5 再把结果当作“加密密码”。前端传来的固定哈希本身会变成可重放的等价密码,传输安全仍应由 TLS 保证。
存量 MD5 或 bcrypt 怎样迁移
不能在离线脚本里把 MD5 直接“升级”为 Argon2id,因为系统并不知道原始密码。可行的是渐进迁移:
- 存储值包含算法标识,例如
{bcrypt}...、{argon2}...; - 登录时根据标识选择旧 encoder 验证;
- 验证成功后,手中暂时拥有用户本次提交的原始密码;
- 用当前首选算法重新编码,并立即覆盖旧值;
- 长期未登录的账号通过密码重置完成迁移。
Spring Security 的 DelegatingPasswordEncoder 正是为多格式验证和未来升级准备的:
String idForEncode = "argon2";
Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("argon2", new Argon2PasswordEncoder(
16, 32, 1, 19 * 1024, 2));
encoders.put("bcrypt", new BCryptPasswordEncoder(12));
PasswordEncoder encoder =
new DelegatingPasswordEncoder(idForEncode, encoders);
对没有 {id} 前缀的历史数据,可以在受控迁移期配置明确的旧格式 matcher,或先批量补上已知前缀。不要把“未知格式”静默当成明文。
登录成功后还应检查 upgradeEncoding(storedHash);如果旧值使用了旧算法或成本过低,就在当前事务中重算并更新。
哈希安全不等于账号安全
密码哈希主要防御数据库泄露后的离线破解,无法代替登录入口的在线防护。完整基线至少还包括:
- 全程使用 TLS;
- 对账号、IP、设备等维度限制失败尝试;
- 错误响应不要泄露“账号是否存在”;
- 支持多因素认证和异常登录提醒;
- 禁止常见或已泄露密码,而不是强迫用户机械组合字符;
- 重置令牌一次性、短有效期,并在使用后立即失效;
- 密码、验证码和重置令牌不得进入日志、埋点或异常上报。
上线前检查清单
- 数据库中没有明文或可逆密文密码;
- 没有直接使用 MD5、SHA-1、SHA-256 保存密码;
- 使用成熟库的 Argon2id、scrypt、bcrypt 或 PBKDF2 实现;
- 每个密码自动生成独立随机盐;
- 参数经过生产同规格硬件压测;
- 存储格式包含算法与参数,能够渐进升级;
- 旧哈希在登录成功后重算,未知格式默认失败;
- 登录限流、MFA、TLS 和日志脱敏没有缺席。