如何在数据库中安全地存储用户密码

账号密码登录是最常见的身份认证方式之一,但“能登录”并不代表“存得安全”。很多系统在功能上完成了登录,实际上却把用户密码暴露在了极高风险之下。

这篇文章想回答一个基础但非常重要的问题:如果要在数据库里存储用户密码,什么做法才算相对安全?

最糟糕的做法:直接存明文密码

最早也最危险的实现方式,是直接把用户名和明文密码存进数据库。

登录时,后端执行类似这样的查询:

1
2
3
4
select count(*)
from user
where username = ${username}
and password = ${input}

如果能查到记录,就认为密码正确。

这种做法的问题非常明显:

  • 数据库一旦泄露,所有用户的密码都会被直接看到;
  • 数据库管理员、运维人员或有读取权限的人也能轻易查看密码;
  • 很多用户会在多个网站复用密码,一次泄露可能波及多个平台;
  • 即使没有人恶意利用,要求用户全量修改密码本身也是巨大的成本。

所以,密码绝不能以明文形式存储。 这是最基本的安全常识。

第二阶段:只存密码的 MD5 值

很多系统会进一步改进,改成在数据库里存储密码的 MD5 值,而不是明文。

登录时,先对用户输入的密码做 MD5,再和数据库中的 MD5 值比对:

1
2
3
4
select count(*)
from user
where username = ${username}
and password_md5 = ${inputMd5}

相比明文存储,这确实前进了一步,因为数据库里不再直接出现用户原始密码。但从今天的安全标准来看,这仍然是不安全的。

为什么单独使用 MD5 不安全?

因为像 MD5 这样的快速哈希算法,存在两个很大的问题:

  1. 同样的输入总会得到同样的输出
  2. 计算速度太快,攻击者可以大规模批量尝试。

这意味着攻击者完全可以预先建立大量“常见密码 → MD5 值”的映射表,也就是常说的彩虹表。数据库一旦泄露,很多弱密码几乎可以被秒级还原。

例如,像 123456admin123password 这种常见密码,其哈希值通常早就被收录在公开数据库里了。攻击者拿到哈希之后,不需要“解密”,只需要查表或暴力尝试,就有很高概率还原出原密码。

这里还要特别澄清一个常见误区:

MD5 不是“加密”,而是哈希。

加密算法设计出来是为了“可逆”,也就是有机会解密回来;而哈希本身是不可逆设计。但“不可逆”不等于“安全”。如果哈希算法太快、输入空间又不够大,攻击者照样可以通过枚举和碰撞手段把大量密码猜出来。

第三阶段:哈希 + 随机盐值

再进一步,很多系统会采用“密码哈希 + 随机盐值(salt)”的方式。

它的大致流程是这样的。

注册时

  1. 用户提交明文密码;
  2. 系统为该用户生成一段随机盐值;
  3. 将“密码 + 盐值”组合后做哈希;
  4. 把哈希结果和盐值一起存入数据库;
  5. 不保存明文密码。

登录时

  1. 根据用户名查出数据库中的盐值和密码哈希;
  2. 用用户刚输入的密码和同一份盐值重新计算哈希;
  3. 将新计算结果与数据库中的哈希值比较;
  4. 一致则登录成功,否则失败。

这种方案比“纯 MD5”安全得多,因为它至少解决了一个核心问题:不同用户即使使用了相同密码,也不会得到相同的存储结果。

这样一来,彩虹表的价值就被大幅削弱了。攻击者即使拿到了数据库,也不能再直接用一套公共映射表批量还原所有密码,而必须针对每个用户逐个尝试。

但这还不够:为什么今天更推荐 bcrypt / scrypt / Argon2?

如果文章只停在“MD5 + 随机盐值”,放在很多年前还算及格,但按今天的标准已经不够好了。

原因很简单:现代密码存储不只是要防查表,还要故意让破解过程变慢。

MD5、SHA1、SHA256 这些通用哈希算法设计目标之一就是“快”。这对文件校验、摘要计算是好事,但对密码存储反而是坏事。

因为攻击者最喜欢的就是“快”。

如果一次猜测只需要极短时间,那么 GPU、ASIC 或大规模并行计算就能在有限时间里尝试海量密码组合。即使你给 MD5 或 SHA256 加了盐,也只是让攻击不能直接查公共彩虹表,但并没有从根本上提高“每次试错”的成本。

这就是为什么现在更推荐专门用于密码存储的算法:

  • bcrypt
  • scrypt
  • Argon2

它们和 MD5 / SHA256 最大的区别在于:它们是故意设计得比较慢的。

更进一步,像 scrypt 和 Argon2 还会提高内存消耗,让大规模并行破解变得更贵。

一个更符合现代实践的结论

如果今天要做一个新系统,我的建议非常直接:

  • 不要明文存密码;
  • 不要只做一次 MD5 或 SHA256;
  • 优先使用成熟库提供的 bcrypt、scrypt 或 Argon2;
  • 不要自己发明密码存储算法。

这点很重要。很多人会觉得:

  • 我把密码先 MD5 一次;
  • 再把盐值也哈希一次;
  • 然后把两个结果拼接;
  • 最后再来一次 SHA256;

这样是不是就比 bcrypt 更安全?

通常不是。

自己设计的组合算法往往只有两种结果:

  1. 增加实现复杂度和维护成本;
  2. 产生一些自己意识不到的安全问题。

而成熟的密码哈希算法和现成实现,已经经过了长期实践和安全研究验证。对于大多数业务系统来说,正确使用成熟方案,远比“自己设计一套看起来很复杂的方案”更可靠。

一个简化的正确思路

如果用伪代码表达,一个现代系统的密码存储逻辑更像这样:

注册

1
2
hash = password_hash(userPassword)
store(username, hash)

登录

1
2
3
4
5
hash = load_hash_by_username(username)
if password_verify(inputPassword, hash):
登录成功
else:
登录失败

注意这里往往不需要你自己手动管理 salt,因为像 bcrypt / Argon2 这类方案通常会把盐值和算法参数一起编码进最终结果里,由框架或库帮你处理。

也就是说,很多时候你真正该做的是:选对算法,调用成熟库,别自己乱造轮子。

还需要注意哪些问题?

密码安全不只是“怎么存”,还包括一整套配套策略。

1. 不要把密码写进日志

有些系统在调试登录失败时,会把请求参数完整打印到日志里。如果里面包含明文密码,那你虽然没把密码存进数据库,却把它存进了日志系统。

这同样危险。

2. 不要把前端哈希当成后端安全

有些人会说:前端先把密码 MD5 一下,再传给后端,不就安全吗?

这并不能替代后端安全存储。

因为前端传过来的“哈希后密码”,在很多场景里已经等价于新的明文密码了。攻击者如果截获这个值,依然可能拿它直接登录。

3. 要有密码强度策略

即使你用了再好的存储算法,用户如果设置的是 123456111111 这种弱密码,系统整体安全性仍然很差。

所以还需要配合:

  • 最低密码长度要求;
  • 黑名单弱密码检测;
  • 必要时启用二次验证。

4. 登录接口要限制暴力尝试

密码即使安全存储了,登录接口如果允许无限次尝试,也一样会被撞库或暴力破解。

常见的补充措施包括:

  • 限制单位时间内的失败次数;
  • 对异常 IP 或账号增加风控;
  • 增加验证码或二次验证;
  • 监控异常登录行为。

最后给一个实用 checklist

如果你正在设计一个用户系统,可以快速自查:

  • 数据库里绝不保存明文密码
  • 不使用纯 MD5 / SHA1 / SHA256 直接存密码
  • 使用 bcrypt / scrypt / Argon2 等专用密码哈希算法
  • 使用成熟框架或安全库,不手写密码存储逻辑
  • 不在日志中记录用户密码
  • 设置基础密码强度规则
  • 对登录失败和异常尝试做限流或风控

总结

密码存储这件事,看起来只是数据库里的一个字段设计,实际上影响的是整个账号体系最基础的安全底线。

从明文,到纯 MD5,再到“加盐哈希”,这是一个很自然的认知演进过程;但如果按今天的实践标准来看,更合理的选择已经很明确了:

使用专门的密码哈希算法,把破解成本尽可能抬高,并且尽量依赖成熟方案而不是自己发明。

只要记住一句话就够了:

存密码的目标,不是“看起来不像明文”,而是“即使数据库泄露,也尽量让攻击者难以利用”。