上一篇我们讲了一个基础概念:
MD5 不是加密,而是摘要 / 哈希。
很多老项目里,我们经常会看到这样的代码:
String password = md5(rawPassword); user.setPassword(password);或者稍微复杂一点:
String password = md5(rawPassword + salt); user.setPassword(password);以前很多人会说:
密码用 MD5 加密一下再存数据库。但严格来说,这句话有两个问题:
1. MD5 不是加密,是哈希 / 摘要。 2. MD5 已经不适合作为现代密码存储方案。那密码到底应该怎么存?
这一篇就专门讲清楚:
1. 密码为什么不能明文存? 2. 密码为什么不需要解密? 3. MD5(password) 为什么不安全? 4. MD5(password + salt) 为什么仍然不够? 5. bcrypt / Argon2id / PBKDF2 是什么? 6. Spring Security 里应该怎么落地? 7. 老项目里的 MD5 密码应该怎么迁移?一、密码绝对不能明文存
最错误的做法是:
username = wu password = 123456也就是数据库里直接保存用户原始密码。
这种问题非常严重。
一旦数据库泄漏,攻击者拿到的就是所有用户的真实密码。
更严重的是,很多用户会在多个平台使用相同密码。
所以一个系统泄漏,可能会导致用户在其他平台也被撞库。
因此,后端不应该保存:
用户原始密码后端应该保存的是:
密码哈希后的结果也就是:
rawPassword ↓ password hash ↓ 存数据库二、密码为什么不需要“解密”?
很多人第一次接触密码哈希时,会有一个疑问:
密码哈希之后无法还原,那用户下次登录时怎么验证?
答案是:
密码验证不需要解密。注册时:
用户输入密码:123456 ↓ 后端做密码哈希 ↓ 数据库保存 password_hash登录时:
用户再次输入密码:123456 ↓ 后端用同样算法重新计算 ↓ 和数据库里的 password_hash 比较如果匹配,说明密码正确。
所以密码验证的核心不是:
数据库里的密码 → 解密成明文 → 比较而是:
用户输入的密码 → 再算一次 hash → 比较 hash也就是说:
密码存储的目标,就是让系统自己也无法还原用户密码。
这也是为什么正规系统一般不提供“找回原密码”,而是提供“重置密码”。
因为系统自己也不应该知道用户原密码。
三、MD5(password) 的问题
早期很多项目会这样存密码:
String encodedPassword = md5(rawPassword);比如:
rawPassword = 123456 MD5(rawPassword) = e10adc3949ba59abbe56e057f20f883e数据库保存:
password = e10adc3949ba59abbe56e057f20f883e登录时:
String inputPassword = request.getPassword(); String inputMd5 = md5(inputPassword); if (inputMd5.equals(user.getPassword())) { // 登录成功 }这套逻辑能跑。
但问题是:
MD5 太快了。快在正常业务里是优点,但在密码存储里反而是缺点。
因为一旦数据库泄漏,攻击者可以疯狂猜密码。
比如攻击者拿到:
e10adc3949ba59abbe56e057f20f883e他不用“解密”,他只需要提前准备常见密码表:
123456 -> e10adc3949ba59abbe56e057f20f883e 111111 -> 96e79218965eb72c92a549dd5a330112 password -> 5f4dcc3b5aa765d61d8327deb882cf99 qwerty -> d8578edf8458ce06fbc5bb76a58c5ca4一查就知道:
e10adc3949ba59abbe56e057f20f883e = 123456这不是 MD5 被“解密”了,而是密码太常见,被猜中了。
四、那加 salt 不就行了吗?
很多项目后来升级成:
String encodedPassword = md5(rawPassword + salt);比如:
password = 123456 salt = abc001 hash = MD5(123456 + abc001)这比单纯 MD5(password) 要好。
因为如果两个用户密码一样,但 salt 不一样,最终 hash 也不一样。
比如:
用户A: password = 123456 salt = abc001 hash = MD5(123456 + abc001) 用户B: password = 123456 salt = xyz999 hash = MD5(123456 + xyz999)这样至少解决了两个问题:
1. 相同密码不会得到相同 hash。 2. 通用彩虹表不能直接套所有用户。但是,MD5 + salt 仍然不够。
为什么?
因为 MD5 还是太快。
攻击者拿到数据库后,通常也能看到 salt。
比如:
username = wu salt = abc001 password_hash = xxxxxx攻击者可以针对这个 salt 重新猜:
MD5(123456 + abc001) MD5(111111 + abc001) MD5(password + abc001) MD5(qwerty + abc001)salt 不需要保密,它只是让每个用户的 hash 独立。
真正的问题是:
攻击者每猜一次的成本太低。所以现代密码存储不能只靠 MD5 + salt。
五、密码存储真正需要什么特性?
密码存储需要的不是“快”,而是“慢”。
准确说,需要这些特性:
1. 不可逆 2. 每个用户不同 salt 3. 计算成本可调 4. 抗暴力猜测 5. 抗 GPU / 专用硬件批量破解MD5 的问题不是不能哈希。
MD5 的问题是:
太快了。密码存储算法应该故意慢。
正常用户登录一次,慢几十毫秒、几百毫秒,用户几乎无感。
但攻击者要猜几千万、几亿个密码时,成本就会被放大。
这就是 bcrypt、Argon2id、PBKDF2 这类算法的意义。
六、bcrypt 是什么?
bcrypt 是一种专门用于密码存储的哈希算法。
它不是加密,不能解密。
它的特点是:
1. 不可逆 2. 自带 salt 3. 有 cost 成本因子 4. 可以故意变慢 5. 适合密码存储bcrypt 生成的结果大概长这样:
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy这串里面包含了:
算法版本 cost 成本因子 salt 最终 hash所以使用 bcrypt 时,通常不需要自己单独设计 salt 字段。
登录验证时,框架会从这串结果中解析出 salt 和 cost,然后用用户输入的密码重新计算并比较。
七、Argon2id 是什么?
Argon2id 是更新一代的密码哈希算法。
你可以简单理解为:
bcrypt:成熟、老牌、兼容性强 Argon2id:更新、更强,强调内存成本Argon2id 不只是让计算变慢,还会增加内存消耗。
这对攻击者批量破解更不友好。
因为攻击者不只是要拼 CPU,还要拼内存成本。
所以在安全要求更高的新系统里,Argon2id 是很好的选择。
不过在很多 Java / Spring 项目里,bcrypt 仍然非常常见。
原因是:
1. Spring Security 支持简单 2. 生态成熟 3. 团队接受度高 4. 上手成本低 5. 线上兼容性好所以实际项目里可以先选 bcrypt,把体系跑通。
八、PBKDF2 是什么?
PBKDF2 也是一种常见的密码派生算法。
它的思路是:
对密码做多轮迭代计算,让计算变慢。比如:
不是 hash 一次,而是 hash 很多次。PBKDF2 标准化时间久,兼容性强,在一些系统和合规场景里也经常见到。
所以常见推荐方案是:
Argon2id bcrypt PBKDF2这三个都比直接 MD5(password) 更适合密码存储。
九、MD5、bcrypt、Argon2id 的区别
可以用这张表理解。
| 算法 | 能不能还原 | 是否适合密码存储 | 主要问题 / 特点 |
|---|---|---|---|
| MD5 | 不能 | 不推荐 | 太快,容易被暴力猜测 |
| SHA-256 | 不能 | 不推荐直接用于密码 | 也是快速哈希 |
| MD5 + salt | 不能 | 不推荐 | 解决相同密码同 hash,但仍然太快 |
| bcrypt | 不能 | 推荐 | 自带 salt,cost 可调,成熟 |
| Argon2id | 不能 | 推荐 | 更新,内存成本更强 |
| PBKDF2 | 不能 | 可用 | 多轮迭代,兼容性好 |
注意:
不能还原不是问题。密码本来就不应该能还原。
真正要看的是:
攻击者猜密码的成本够不够高。十、Spring Security 里怎么落地?
在 Spring Security 里,不建议自己手写 MD5。
更推荐使用 PasswordEncoder。
比如使用 bcrypt:
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }注册时:
public void register(RegisterRequest request) { String rawPassword = request.getPassword(); String encodedPassword = passwordEncoder.encode(rawPassword); User user = new User(); user.setUsername(request.getUsername()); user.setPassword(encodedPassword); userMapper.insert(user); }登录时:
public boolean login(LoginRequest request) { User user = userMapper.findByUsername(request.getUsername()); if (user == null) { return false; } return passwordEncoder.matches( request.getPassword(), user.getPassword() ); }这里的关键是:
passwordEncoder.matches(rawPassword, encodedPassword)matches()不是解密。
它是用用户输入的原始密码,结合 encodedPassword 中的算法信息、salt、cost,重新计算并比较。
十一、为什么不建议客户端 MD5 后再传?
很多 App 老项目里会这样:
val passwordMd5 = md5(password) api.login(username, passwordMd5)看起来好像安全,因为没有直接传原始密码。
但问题是:
passwordMd5 本身变成了等价密码。也就是说,后端如果认可这个 MD5 值登录,那么攻击者只要拿到这个 MD5 值,就不需要知道原始密码,也可以直接登录。
所以客户端 MD5 不是安全方案。
正确做法通常是:
客户端输入原始密码 ↓ 通过 HTTPS/TLS 传输 ↓ 后端用 bcrypt / Argon2id / PBKDF2 校验注意,这里的“原始密码”不是 HTTP 明文裸奔。
它必须走 HTTPS/TLS 加密通道。
客户端要做的是:
不保存密码 不打印密码 不把密码放 URL 不自己用 MD5 伪装成安全十二、密码和 Token 的处理方式不一样
这个点非常关键。
密码和 Token 不是一类东西。
1. 密码
密码后端只需要验证,不需要还原。
所以用:
bcrypt / Argon2id / PBKDF2也就是不可逆哈希。
流程:
用户输入密码 ↓ 密码哈希 ↓ 数据库保存 hash2. Token
Token 后续还要拿出来请求接口。
比如:
Authorization: Bearer accessToken所以 Token 不能用 MD5。
如果你写:
MD5(token)那后面就拿不回原始 Token 了。
Token 本地存储应该用:
AES-GCM 加密 Android Keystore 保护 AES key流程:
Token 明文 ↓ AES-GCM 加密 ↓ Token 密文 ↓ 本地保存请求时:
Token 密文 ↓ AES-GCM 解密 ↓ Token 明文 ↓ Authorization Header所以一句话:
密码用哈希,因为不需要还原;Token 用加密,因为后面还要还原出来使用。
十三、老项目已经用了 MD5,怎么办?
如果老项目数据库里已经存了:
password = MD5(password)或者:
password = MD5(password + salt)不要直接全部改成 bcrypt。
因为你没有用户原始密码,无法直接重新计算 bcrypt。
正确方式是:
兼容旧密码 登录成功后升级流程:
用户登录 ↓ 发现数据库里是旧 MD5 格式 ↓ 用旧 MD5 逻辑验证 ↓ 验证成功 ↓ 拿到用户这次输入的原始密码 ↓ 重新生成 bcrypt ↓ 更新数据库这样用户无感知,系统逐步迁移。
十四、密码算法最好带版本标识
为了支持迁移,数据库里的密码字段最好能看出算法。
比如:
{md5}e10adc3949ba59abbe56e057f20f883e {bcrypt}$2a$10$xxxxxxxxxxxxxxxx或者单独加字段:
password_hash password_algo我更推荐第一种风格,因为很多框架也支持类似格式。
例如:
{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMye...这样登录时可以根据前缀判断:
{md5} → 用旧 MD5 校验 {bcrypt} → 用 BCryptPasswordEncoder 校验十五、MD5 到 bcrypt 的迁移示例
示例代码:
public boolean login(String username, String rawPassword) { User user = userMapper.findByUsername(username); if (user == null) { return false; } String storedPassword = user.getPassword(); if (storedPassword.startsWith("{md5}")) { String oldHash = storedPassword.replace("{md5}", ""); String inputHash = md5(rawPassword); if (oldHash.equalsIgnoreCase(inputHash)) { String newHash = "{bcrypt}" + passwordEncoder.encode(rawPassword); userMapper.updatePassword(user.getId(), newHash); return true; } return false; } if (storedPassword.startsWith("{bcrypt}")) { String bcryptHash = storedPassword.replace("{bcrypt}", ""); return passwordEncoder.matches(rawPassword, bcryptHash); } return false; }这个逻辑的核心是:
旧用户第一次登录时,顺手升级密码算法。这样不需要强制所有用户改密码。
但如果安全风险较高,也可以要求用户重置密码。
十六、Spring Security 的 DelegatingPasswordEncoder
Spring Security 里还有一个比较适合迁移场景的设计:
DelegatingPasswordEncoder它的思想就是:
一个 PasswordEncoder 支持多种算法。数据库密码格式类似:
{bcrypt}$2a$10$xxxx {noop}123456 {pbkdf2}xxxx前面的{bcrypt}、{pbkdf2}就是算法标识。
登录时根据标识选择对应的 PasswordEncoder。
这个思想很适合老系统迁移。
你也可以自己实现类似逻辑。
十七、密码存储还要配合哪些安全措施?
密码哈希不是唯一安全措施。
完整登录安全还包括:
1. 登录接口必须走 HTTPS。 2. 密码不能打印日志。 3. 登录失败要限流。 4. 多次失败可以加验证码。 5. 高风险登录可以短信 / 邮箱验证。 6. 修改密码后让旧 Token 失效。 7. 数据库密码字段不能返回给前端。 8. 管理后台不能展示用户密码。 9. 生产环境不能把请求体完整打到日志里。密码哈希解决的是:
数据库泄漏后,密码不容易被还原。但它不解决:
接口暴力破解 日志泄漏密码 弱密码 撞库攻击 Token 泄漏所以密码安全要放在整个认证体系里看。
十八、常见错误总结
错误 1:密码明文存数据库
严重错误。
password = 123456错误 2:认为 MD5 是加密
不准确。
MD5 是摘要,不能解密。
错误 3:客户端 MD5 后再传就安全了
不对。
MD5 值会变成等价密码。
错误 4:MD5 + salt 就足够了
不够。
salt 解决相同密码同 hash 和彩虹表问题,但 MD5 仍然太快。
错误 5:登录时把数据库密码解密出来比较
不应该这样设计。
密码应该不可逆,登录时重新计算 hash 后比较。
错误 6:用 AES 加密密码存数据库
也不推荐。
如果用 AES,说明后端理论上可以解密出用户原始密码。
密码存储不应该可逆。
密码应该使用专门的密码哈希算法。
十九、最终建议
如果是新项目,建议直接:
bcrypt / Argon2id / PBKDF2如果是 Spring Boot / Spring Security 项目,先用 bcrypt 就够了:
@Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }如果是老项目已经用了 MD5:
不要直接强行替换。 先兼容旧 MD5。 用户登录成功后,升级成 bcrypt。如果安全要求更高:
可以考虑 Argon2id。 可以考虑 pepper。 可以增加登录限流、验证码、MFA、风控。二十、总结
密码到底怎么存?
一句话:
密码不应该明文存,也不应该用 MD5 简单摘要存,而应该使用 bcrypt / Argon2id / PBKDF2 这类专门的密码哈希算法。
再展开一点:
密码不需要还原,所以不要用可逆加密。 MD5 不是加密,是摘要。 MD5 太快,不适合现代密码存储。 salt 可以让相同密码 hash 不一样,但不能解决 MD5 太快的问题。 bcrypt / Argon2id / PBKDF2 会故意提高计算成本,让攻击者批量猜密码变得更困难。 登录验证不是解密密码,而是用用户输入的密码重新计算后比较。 老项目 MD5 密码可以通过“登录成功后升级”的方式平滑迁移。如果用一句话串起来:
密码用哈希,因为不需要还原;Token 用加密,因为后面还要使用。密码哈希要慢,Token 加密要可逆。
这句话理解了,密码存储和 Token 存储就不会再混了。