恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
密码加盐实战:从MD5到PBKDF2与BCrypt的完整指南
首页
资讯中心
/
密码加盐实战:从MD5到PBKDF2与BCrypt的完整指南
密码加盐实战:从MD5到PBKDF2与BCrypt的完整指南
发布时间:2026/8/30 12:16:34
很多后端同学在做账号体系的时候都会遇到同一个问题密码到底能不能直接做一次 MD5 再存数据库网上说法很多有的说 MD5 不安全有的说加盐之后就可以还有的提到了 BCrypt、PBKDF2、Argon2 这些名词。如果你是从热搜词salt或者salt player open进到这篇文章先说清楚一个概念这里讨论的并不是某个叫 Salt 的开源播放器而是密码学中非常核心的“加盐Salt”机制。在项目评审里“开启加盐”经常会被翻译成五花八门的中文本质上都是要求密码不能裸哈希存储。这篇文章我会围绕密码加盐展开把盐是什么、盐解决什么问题、如何用 Java 原生 API 实现一套可落地的加盐哈希方案、以及 Spring Security 中 BCrypt 的自动加盐机制都梳理一遍。内容偏实战代码可以直接复制到本地项目里运行最后还会整理高频报错和工程建议适合正在做账号系统、登录模块、安全改造的后端开发者。1. 为什么密码不能只做哈希存储1.1 哈希算法是单向的但不是为密码设计很多人刚接触安全时会以为“MD5 是不可逆的所以密码存 MD5 就是安全的”。这个理解存在两个偏差。第一哈希算法确实是单向函数从哈希值反推原始输入在计算上非常困难。但 MD5、SHA-1、SHA-256 这类通用哈希算法在设计目标上追求的是“高效”它们运行速度非常快。对于开发者来说快是好事对于攻击者来说快同样是好事。攻击者可以在一秒内尝试数十亿次密码猜测直接把常见密码字典里的每一个词都做一次哈希然后和你数据库里的哈希值比对。第二“不可逆”只表示不能从哈希值直接还原出明文但攻击者根本不需要还原。他们只需要用常见密码库去碰撞。如果你的用户密码是123456那几乎任何一本预计算字典里都有它的 MD5 值。1.2 相同密码产生相同哈希值导致的问题通用哈希算法是确定性的同样的输入永远得到同样的输出。这带来一个非常严重的问题如果两个用户都设置了admin123那么他们的密码哈希值完全一样。攻击者一旦破解出其中一个人的明文就等于同时知道了所有相同哈希值对应的明文。即便只是从统计角度他也可以判断出哪些用户使用了相同密码这给撞库攻击提供了很大便利。更经典的风险是彩虹表。彩虹表是一种用空间换时间的预计算结果集合攻击者提前把海量密码的哈希算好并建立索引。在彩虹表面前没有加盐的 MD5、SHA-1 基本等同于明文。虽然彩虹表不能覆盖所有密码但覆盖几亿条常见弱密码是没有任何压力的。1.3 加盐之后效果完全不同给密码加盐就是在用户输入的原始密码后面拼接一段随机数据然后再做哈希。MD5(password) MD5(password salt)这两者最大的区别在于前者只要密码相同哈希值就固定后者因为每次注册都会生成一段新的随机盐所以即使两个用户使用同一个密码最终的哈希值也完全不同。盐的作用本质上是把“指定密码的哈希”变成“指定密码在指定盐下的哈希”。攻击者如果要破解就无法复用预先算好的彩虹表只能针对每个盐值单独做暴力破解。这样一来破解成本被大幅提高。2. 密码加盐的核心概念2.1 盐的本质盐是一串随机数据通常以字节数组的形式存在再通过 Base64 编码存储。它本身并不是秘密甚至可以和最终的密码哈希值存放在同一个数据库字段里。这听起来可能有些反直觉很多人会问“盐如果不保密加了有什么用”盐并不是靠“保密”来生效的它的核心价值是“随机性”。攻击者可以拿到你的盐但他依然需要针对这个随机盐重新计算字典里的每一个候选密码。假设你的盐有 128 位随机熵攻击者想要预计算一张覆盖所有可能盐和密码组合的彩虹表这在计算上是不可能完成的。2.2 盐的关键属性第一盐必须足够随机。这里的“随机”指的是密码学意义上的随机要使用SecureRandom这类加密安全伪随机数生成器而不是Math.random()。Math.random()的随机性和安全性都不够强在密码安全场景下不能使用。第二盐的长度不能太短。一般建议 16 字节也就是 128 位。太短的话攻击者仍然可能通过大量预计算覆盖常见组合。第三每个用户每次注册都必须使用新的盐不能全项目共用一个固定盐。如果使用固定盐虽然可以防止彩虹表攻击但两个相同密码的用户仍然会得到相同哈希而且一旦攻击者分析出那个固定盐整个项目的密码体系就全部暴露了。第四盐要和密码哈希一起存储。这一点很重要。很多同学会把加盐机制想复杂认为盐也要单独加密保存。其实不需要只要盐的随机性足够它完全可以以明文方式放在存储字符串中。2.3 盐与胡椒的区别在安全资料中你可能会看到两个相近的概念Salt盐和 Pepper胡椒。盐是随机的、不保密的、每个用户不同的数据。胡椒是一个全局的、保密的、可以作为应用级密钥的数据。例如hash H(password salt pepper)胡椒不能和密码哈希存在同一个数据库里通常放在独立的配置中心、环境变量或者密钥管理系统中。一旦 pepper 泄露它的保密价值也就消失了。实际开发中加盐通常是必选项而胡椒属于额外加固手段。如果是刚开始做安全改造先保证盐的实现正确再考虑引入胡椒。3. 环境准备与版本说明本文的实战示例以 Java 语言为主具体环境如下操作系统Windows / Linux / macOS 均可JDKJDK 8 以上即可运行示例代码默认以 JDK 17 演示构建工具Maven 3.6 或直接使用 IDE 内置依赖管理IDEIntelliJ IDEA 或 Eclipse框架第四章使用 JDK 原生 API不依赖第三方库第五章使用 Spring Boot 3.x Spring Security 6.x如果你本地的 JDK 版本不同不需要担心。第四章里用到的SecureRandom、PBEKeySpec、SecretKeyFactory都是 JDK 自带类从 Java 8 开始就稳定存在。版本不同的主要影响是安全策略和算法支持细节核心代码不用改动。4. 使用 Java 原生 API 实现密码加盐这一章我们会实现一个完整的密码加盐、哈希、编码、校验工具类。整个过程不使用任何第三方库方便你先理解底层原理。4.1 项目结构与依赖先创建一个普通的 Maven 项目结构如下password-salt-demo ├── pom.xml └── src └── main └── java └── com └── example └── password ├── PasswordUtil.java └── PasswordDemo.java这里不需要在pom.xml中额外引入依赖因为我们会使用 JDK 自带的密码学 API。下面是可以直接使用的pom.xml?xml version1.0 encodingUTF-8? project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdpassword-salt-demo/artifactId version1.0-SNAPSHOT/version packagingjar/packaging properties maven.compiler.source17/maven.compiler.source maven.compiler.target17/maven.compiler.target project.build.sourceEncodingUTF-8/project.build.sourceEncoding /properties /project4.2 生成随机盐生成盐的核心是使用SecureRandom。这个类提供了加密安全的随机数生成能力系统会尽可能从操作系统底层随机源获取熵。package com.example.password; import java.security.SecureRandom; import java.util.Base64; public final class PasswordSaltGenerator { private static final SecureRandom SECURE_RANDOM new SecureRandom(); private PasswordSaltGenerator() { } public static String generateSalt(int byteLength) { byte[] salt new byte[byteLength]; SECURE_RANDOM.nextBytes(salt); return Base64.getEncoder().encodeToString(salt); } public static void main(String[] args) { String salt1 generateSalt(16); String salt2 generateSalt(16); System.out.println(第一次生成盐: salt1); System.out.println(第二次生成盐: salt2); System.out.println(两次盐是否相同: salt1.equals(salt2)); } }运行后你会发现每次生成的盐都不相同。这里把SecureRandom声明为静态常量是推荐的做法。频繁创建SecureRandom实例可能会出现性能问题而复用同一个实例既可以保证随机源质量也能减少开销。注意byteLength参数代表的是随机字节数。16 字节等于 128 位这一步在生产环境中至少要保证达到该长度。4.3 密码哈希与编码格式有了盐之后下一步就是使用 PBKDF2 算法计算密码哈希。PBKDF2 的全称是 Password-Based Key Derivation Function 2它通过反复执行伪随机函数来增加暴力破解的成本。Java 中可以使用SecretKeyFactory配合PBEKeySpec来实现。我们将完整定义密码的存储格式。推荐使用类似下面的结构pbkdf2_sha256$迭代次数$盐$哈希值使用$作为分隔符好处是后续解析时非常容易也方便未来切换算法。例如从 PBKDF2 切换到 BCrypt 时可以通过前缀区分。下面是完整的PasswordUtil代码package com.example.password; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.PBEKeySpec; import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Base64; public final class PasswordUtil { private static final int SALT_BYTE_SIZE 16; private static final int ITERATIONS 100_000; private static final int HASH_BIT_SIZE 256; private static final String ALGORITHM PBKDF2WithHmacSHA256; private static final SecureRandom SECURE_RANDOM new SecureRandom(); private PasswordUtil() { } /** * 生成随机盐 */ public static String generateSalt() { byte[] salt new byte[SALT_BYTE_SIZE]; SECURE_RANDOM.nextBytes(salt); return Base64.getEncoder().encodeToString(salt); } /** * 生成加密后的密码字符串 */ public static String encode(String password) throws Exception { String salt generateSalt(); String hash pbkdf2(password, salt, ITERATIONS, HASH_BIT_SIZE); return pbkdf2_sha256$ ITERATIONS $ salt $ hash; } /** * 校验密码 */ public static boolean verify(String password, String encoded) throws Exception { String[] parts encoded.split(\\$); if (parts.length ! 4) { throw new IllegalArgumentException(密码存储格式不正确); } String algorithm parts[0]; int iterations Integer.parseInt(parts[1]); String salt parts[2]; String expectedHash parts[3]; if (!pbkdf2_sha256.equals(algorithm)) { throw new IllegalArgumentException(不支持的算法: algorithm); } String actualHash pbkdf2(password, salt, iterations, HASH_BIT_SIZE); return MessageDigest.isEqual( Base64.getDecoder().decode(actualHash), Base64.getDecoder().decode(expectedHash) ); } private static String pbkdf2(String password, String salt, int iterations, int keyLengthBits) throws Exception { byte[] saltBytes Base64.getDecoder().decode(salt); PBEKeySpec spec new PBEKeySpec( password.toCharArray(), saltBytes, iterations, keyLengthBits ); SecretKeyFactory factory SecretKeyFactory.getInstance(ALGORITHM); byte[] hashBytes factory.generateSecret(spec).getEncoded(); return Base64.getEncoder().encodeToString(hashBytes); } }这段代码中的几个关键点需要说明。PBEKeySpec的第一个参数使用char[]而不是String这是安全编码的常见实践。字符串是不可变对象会一直存在于内存中而字符数组用完之后可以手动清空。虽然示例中没有显式清空但你已经可以看到char[]的用法。ITERATIONS是迭代次数默认设置为 100000。迭代次数直接影响两个指标一方面是哈希计算速度另一方面是攻击者暴力破解的成本。次数越大破解成本越高但正常用户登录时的耗时也会增加。这里的 100000 适合作为入门示例生产环境需要根据服务器性能和最新安全建议调整后面最佳实践部分会再细说。比较哈希值时使用了MessageDigest.isEqual而不是直接调用equals。MessageDigest.isEqual会使用常量时间比较避免通过时间差异泄露信息能够减少时序攻击风险。4.4 密码校验密码校验是登录功能的核心。校验流程并不复杂从存储字符串中拆出盐和迭代次数再用用户输入的明文密码重新计算哈希最后对比两个哈希值是否相等。这里必须强调一个容易出错的地方校验时不要重新生成新盐。盐是随机的重新生成会导致哈希永远对不上。校验使用的盐必须来自数据库里存储的那条记录。下面是完整的演示类package com.example.password; public class PasswordDemo { public static void main(String[] args) throws Exception { String rawPassword csdn-demo-123; String encoded1 PasswordUtil.encode(rawPassword); String encoded2 PasswordUtil.encode(rawPassword); System.out.println(第一次加密结果: encoded1); System.out.println(第二次加密结果: encoded2); System.out.println(两次结果是否相同: encoded1.equals(encoded2)); System.out.println(); System.out.println(正确密码校验: PasswordUtil.verify(rawPassword, encoded1)); System.out.println(错误密码校验: PasswordUtil.verify(wrong-pass, encoded1)); } }4.5 运行与验证运行main方法后你大概率会看到类似下面的输出第一次加密结果: pbkdf2_sha256$100000$T9m8w6Xa2Ir1HdJkYwF1eA$9sZg1Vv... 第二次加密结果: pbkdf2_sha256$100000$QmFzZTY0U2FsdA$4Fq2Xv... 两次结果是否相同: false 正确密码校验: true 错误密码校验: false输出结果说明了两件事。第一同一个原始密码经过盐处理后得到的是两个完全不同的编码字符串。第二校验逻辑可以正确区分正确密码和错误密码。这个工具类已经具备在生产代码中使用的雏形。你可以把encode的返回值存到数据库的password字段登录时取出该字段再调用verify完成校验。整个过程不依赖任何后端框架适合理解原理也适合被封装到公共工具库中。5. 用 Spring Security BCrypt 自动处理盐理解了手写加盐过程之后再来看 Spring Security 中常见的 BCrypt 方案思路就清晰很多。BCrypt 是一种自带盐的密码哈希算法它不需要像 PBKDF2 那样手动生成盐并单独存储。5.1 BCrypt 为什么更推荐BCrypt 的优势主要有三点。第一BCrypt 自动包含随机盐。每个密码经过 BCrypt 处理后输出的字符串本身就带有盐和成本因子开发者不需要关心盐的生成、拼接和存储。第二BCrypt 被设计为计算密集型算法。它会执行多次 Blowfish 密钥扩展天然比 MD5、SHA-256 慢很多。这个“慢”正是密码哈希想要的特性。第三BCrypt 支持成本因子调整。随着硬件性能提升可以逐渐提高成本因子增加攻击难度而不需要改变存储格式。5.2 引入依赖如果你使用的是 Spring Boot 3.x可以先创建标准项目然后引入 Spring Security 依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency引入该依赖后Spring Boot 会自动启用默认安全配置。如果没有额外配置访问任何接口都需要登录并生成一个随机密码打印在启动日志里。这是符合预期的我们再通过配置类显式定义PasswordEncoder。5.3 配置 PasswordEncoder新建配置类package com.example.security.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; Configuration public class SecurityConfig { Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } }这里的BCryptPasswordEncoder默认使用强度为 10 的成本因子。如果你希望提高强度可以通过构造参数指定return new BCryptPasswordEncoder(12);成本因子越大计算耗时越长。需要根据项目实际访问量和服务器性能权衡建议压测后再调整不要盲目调到特别大的数值。5.4 注册和登录示例在业务代码中我们只依赖PasswordEncoder接口避免直接耦合 BCrypt 实现。注册逻辑如下package com.example.security.service; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; Service public class UserService { private final PasswordEncoder passwordEncoder; public UserService(PasswordEncoder passwordEncoder) { this.passwordEncoder passwordEncoder; } public void register(String username, String rawPassword) { String encodedPassword passwordEncoder.encode(rawPassword); System.out.println(需要写入数据库的密码: encodedPassword); // 执行 userMapper.insert(username, encodedPassword) } public boolean login(String username, String rawPassword) { // 从数据库查出该用户存储的密码哈希 String encodedPassword queryEncodedPasswordByUsername(username); return passwordEncoder.matches(rawPassword, encodedPassword); } private String queryEncodedPasswordByUsername(String username) { // 实际项目中替换为数据库查询 return $2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy; } }注册时调用passwordEncoder.encode(rawPassword)返回的字符串以$2a$开头其中包含版本标识、成本因子、盐和哈希值。登录时调用passwordEncoder.matches(rawPassword, encodedPassword)方法内部会自动解析出盐和成本因子再重新计算并比较。这里有一个非常常见的误区登录校验时一定不要先对密码做encode再matches。matches的第一个参数必须是明文密码第二个参数才是数据库中的哈希值。如果你的代码写成了matches(encode(rawPassword), encodedPassword)结果几乎必然是 false而且会让 BCrypt 莫名其妙地多计算一次哈希。5.5 BCrypt 校验原理简析BCrypt 生成的字符串结构大致如下$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy其中$2a$是算法版本10是成本因子后面 22 个字符是盐再往后是哈希值。因为它把盐和哈希放在了同一个字符串中所以校验时不需要额外查询盐字段。这也是 BCrypt 相比“手动加盐 PBKDF2”更方便的原因。如果登录接口报错或者密码始终对不上可以先检查数据库里的密码字符串是否完整很多情况下是因为字段长度不足导致末尾部分被截断。数据库字段建议设置为VARCHAR(255)至少也要 100 个字符否则存储会出问题。6. 常见问题与排查思路6.1 常见问题对照表我在做账号安全改造时收集过不少实际遇到的高频问题。下面用表格直接列出现象、原因和解决思路问题现象常见原因解决思路两个相同密码的用户数据库里的密码哈希完全一样使用固定盐或者完全没有加盐每个用户注册时生成新的随机盐使用Math.random()生成盐随机性不够存在安全风险改用SecureRandom登录时密码校验总是失败校验时重新生成了新的盐或传入的密码已经被 encode 过校验时使用数据库里的盐matches第一个参数必须是明文PBKDF2 算法报NoSuchAlgorithmExceptionJDK 版本过低或算法名称拼写错误使用 JDK 8确认算法名称为PBKDF2WithHmacSHA256数据库插入密码字段报错字段长度不够编码字符串被截断将密码字段改为VARCHAR(255)登录接口耗时明显增加迭代次数或 BCrypt 成本因子设置过高结合服务器 CPU 性能压测适当降低参数数据库中哈希值以$2a$开头却使用 PBKDF2 的verify去校验新旧算法混用没有根据前缀区分解析存储字符串时先识别算法名再走不同校验逻辑直接对哈希值做解密哈希是单向的不能解密用户忘记密码时重置而不是找回原密码6.2 一个典型误区的说明有同学会在测试时发现同一台机器上对同一个密码执行两次passwordEncoder.encode()得到的字符串完全不同于是怀疑代码有问题。这个现象其实是正常的因为 BCrypt 每次都会生成随机盐。判断不能靠“两次 encode 的结果是否一致”而应该用matches方法对第一次的编码结果做校验。只要matches返回 true说明整个加盐、哈希、存储、校验链路是通的。如果你在代码评审中看到有同事断言“BCrypt 加盐后密码固定”那说明他对盐的机制理解还有偏差。每次随机盐的价值就在于此它让攻击者无法通过预计算表批量破解。7. 最佳实践与工程建议7.1 算法选择密码哈希算法不是越新越好而是越适合密码存储越好。目前主流的密码哈希算法包括 BCrypt、PBKDF2、scrypt、Argon2。不同语言和框架的支持情况不同Java 生态中最常见的是 BCrypt 和 PBKDF2。如果项目已经引入了 Spring Security直接用BCryptPasswordEncoder是最省事的方案安全强度有保障而且实现经过了大量社区验证。如果项目不方便引入额外依赖使用 JDK 自带的 PBKDF2 也是正确选择。重点不是纠结哪个算法天下第一而是不要再使用单纯的 MD5、SHA-1、SHA-256 直接存密码。7.2 盐的生成与管理盐必须由密码学安全的伪随机数生成器产生。Java 中就是SecureRandom。在实现时应遵循以下原则每个用户每次注册都要新生成一个盐。盐的字节长度至少 16。盐不需要加密存储。盐和哈希值建议拼接为一个格式字符串降低字段管理复杂度。不要在前端页面上暴露用户的盐或哈希值。7.3 密码存储格式统一存储格式非常重要。推荐使用算法名$迭代次数$盐$哈希这种带元信息的格式。这样未来算法升级时系统可以根据前缀判断新旧哈希实现平滑过渡。举个例子你现在的存量用户可能都是md5$xxxxxxxx新用户是bcrypt$xxxxxxxx。登录校验时代码先看前缀如果是旧格式就先把旧密码迁移成新格式再写入数据库。这个“登录时渐进式迁移”的策略比一次全量迁移要安全得多。if (encodedPassword.startsWith(md5$)) { boolean ok legacyMd5Verify(rawPassword, encodedPassword); if (ok) { String newEncoded passwordEncoder.encode(rawPassword); updatePassword(username, newEncoded); } return ok; } return passwordEncoder.matches(rawPassword, encodedPassword);7.4 合规与审计密码存储属于账号安全体系的一部分上线前建议自查以下几点是否禁止了弱密码比如长度低于 8 位、纯数字、常见密码列表。是否对登录失败次数做了限制防止暴力破解。是否在日志中打印了密码、密码哈希或盐。日志中禁止出现任何凭证相关敏感信息。数据库账号是否遵循最小权限原则生产环境不要用高权限账号执行业务 SQL。是否支持用户主动修改密码和注销会话。7.5 生产环境改造建议如果是存量系统不建议直接全表重算密码。更稳妥的方式是先上线新算法让新注册用户使用新格式同时通过登录校验完成老用户渐进式迁移。整个过程中需要注意以下几个风险点修改密码字段长度前先在测试环境验证所有 SQL 和索引。如果使用 Spring Security注意引入依赖后默认拦截所有接口需要提前确认哪些接口需要放行。如果使用自研PasswordUtil建议补充单元测试覆盖正确密码、错误密码、空密码、超长密码等边界情况。迭代次数和 BCrypt 成本因子不宜设置过高。压测时关注登录接口的 TP99 耗时通常建议单次哈希耗时控制在 100ms 到 300ms 之间具体根据业务场景权衡。8. 总结与下一步学习建议密码加盐是账号体系安全改造中绕不开的一环。通过本文你应该已经理解了盐的本质、盐与哈希的关系、手动实现 PBKDF2 加盐的完整流程以及 Spring Security BCrypt 如何自动管理盐。同时你也知道了为什么不能直接使用 MD5 存密码以及在登录校验中容易踩的坑。如果接下来想继续深入建议按以下顺序学习先阅读 Spring Security 官方文档中关于 Password Storage 的部分。再对比研究 BCrypt、scrypt、Argon2 三种算法的设计目标和适用场景。然后了解 WebAuthn 和无密码登录方案看看是否能在自己的项目中引入更现代的身份认证方式。最后结合项目实际情况把密码重置、找回、登录限流、账号锁定等内容一起补全。安全没有绝对的一劳永逸每次硬件性能提升后都需要重新评估哈希算法的成本参数。但无论技术怎么演进加盐这个基础设计都不会过时。哪怕是做内部管理系统、毕业设计、个人博客项目只要涉及账号密码都应该把加盐这件事写进代码规范里。