不知道你有没有听过一句话:代码里最不能缺的两样东西,一个是“盐”,一个是“配置”。这里说的“盐”就是信息安全领域里经常提到的 Salt(盐值)。今天这篇不是讲美食,而是带着“要来一口盐巴吗”这个谐音梗,把 Spring Security 中密码加盐(BCrypt)这件事从头到尾讲清楚。如果你正在做用户注册登录模块,或者经常被“密码是明文存的吗”“为什么同一个密码每次加密结果不一样”这些问题困扰,这篇文章应该能帮你一次性理顺。
本文会从“为什么密码不能明文存储”“哈希和加盐到底是什么关系”开始,逐步拆解 BCrypt 的核心原理,再给出一个可直接运行的 Spring Boot 实战项目,包含注册、登录、数据库表设计、常见报错排查,以及我在工程中总结出来的一些最佳实践。适合后端开发新手,也适合想系统补一补密码存储安全的进阶读者。
1. 背景与核心概念
1.1 为什么数据库里的密码不能明文保存
很多刚入行的同学会把用户密码直接存进数据库,比如在一个sys_user表里,password字段放的是用户输入的123456。这种做法的风险非常大,因为数据库一旦被拖库,所有用户的密码都会直接暴露。现实世界中很多用户在不同平台使用同一个密码,一个网站的密码泄露,可能会导致其他平台的账号也被撞库。
正确做法是:数据库里存的不应该是原始密码,而是密码的“哈希值”。哈希算法是一种单向函数,从原始密码可以算出一个固定长度的字符串,但从哈希值几乎无法反推出原始密码。也就是说,即使数据库泄露,攻击者拿到的也是一堆无法直接还原成明文密码的哈希串。
这里要区分两个概念:哈希(Hash)不是加密(Encryption)。加密是可逆的,加密后的密文可以通过密钥还原成明文;哈希是不可逆的,同一个输入对应同一个输出,但输出不能还原输入。所以准确地说,我们不是给密码“加密”,而是给密码做“不可逆哈希”。
既然哈希不可逆,为什么还要“加盐”?因为直接哈希仍然不安全。下面这一节就来解释这个问题。
1.2 什么是盐(Salt),为什么要加盐
假设用户密码是123456,用 MD5 计算得到的结果是:
e10adc3949ba59abbe56e057f20f883e这个结果是固定的。攻击者可以提前把常见弱密码全部算出 MD5 值,制作成一张“彩虹表”。拿到数据库中的哈希后,直接查表就能反推出明文密码。另外,如果有两个用户密码相同,那么他俩的哈希值也完全相同,这等于告诉攻击者这两个人的密码一样,进一步放大了风险。
解决办法就是加盐。盐(Salt)是一段随机生成的字符串,在计算哈希之前,把盐拼接到密码后面,再一起做哈希:
哈希值 = Hash(明文密码 + 随机盐)加了盐之后:
- 即使两个用户密码相同,只要盐不同,哈希结果就不同。
- 攻击者需要针对每个盐重新生成彩虹表,攻击成本成倍上升。
- 如果盐足够长、足够随机,用彩虹表破解变得不现实。
盐需要在验证时被重新使用,所以盐一般会和哈希值一起存储,或者直接拼接在哈希结果中。这不是秘密,盐不需要保密,它的作用是增大攻击成本,而不是作为密钥存在。下图是一个简化流程:
注册时: 用户输入密码 123456 系统生成随机盐 S 计算 hash = BCrypt(123456 + S) 存储 "hash 中自带盐" 登录时: 用户输入密码 123456 取出存储 hash,解析出其中的盐 S 重新计算 BCrypt(123456 + S) 对比是否等于存储 hash1.3 MD5、SHA 与 BCrypt 的本质区别
很多旧项目喜欢用MD5或者SHA-256直接处理密码,这类算法本质是“快速摘要算法”。计算速度极快,在 CPU 上每秒可以完成数亿次计算。攻击者可以用 GPU 暴力穷举所有 8 位数字密码,可能只需要几分钟甚至更快。
BCrypt 与它们最大的不同在于:BCrypt 是一种“慢哈希”算法。它通过反复迭代的方式,故意让单次计算变得很慢,从而极大提高暴力破解的成本。默认情况下,BCrypt 会进行2^10轮迭代计算,生成一个 60 字符左右的字符串。虽然单次注册、登录慢几十毫秒,用户无感知,但攻击者想在相同时间内穷举亿万个密码,资源和时间成本都会暴涨。
而且 BCrypt 最大的特点是“自带盐”。每次调用encode方法时,BCrypt 都会自动生成一个随机盐,把盐、算法版本、迭代次数和最终哈希合并成一个字符串返回。所以同一个密码,每次生成的哈希结果都不一样,但用matches方法依然能够校验通过。
下面用一张表格来对比常见的几种处理方式:
| 方案 | 是否随机盐 | 是否慢哈希 | 抗彩虹表 | 安全性 |
|---|---|---|---|---|
| 明文存储 | 无 | 否 | 差 | 不可接受 |
| MD5(password) | 无 | 否 | 差 | 不建议 |
| MD5(password + 固定盐) | 否 | 否 | 弱 | 避免使用 |
| SHA-256(password + 随机盐) | 是 | 否 | 一般 | 可过渡,仍非首选 |
| BCrypt(随机盐) | 是 | 是 | 强 | 推荐 |
| Argon2/scrypt | 是 | 是 | 强 | 安全敏感系统可选 |
2. 环境准备与版本说明
本文的实战案例是一个 Spring Boot 项目,示例环境如下:
- JDK:8 或 11 或 17,不同版本都可以跑,建议使用项目团队统一的 JDK 版本。
- Spring Boot:本文示例使用
2.7.18。如果你的项目已经升级到 Spring Boot 3.x,依赖坐标不变,但 Spring Security 6.x 中的部分配置写法做了调整,文章里会特别说明。 - Spring Security:Spring Boot 2.7 默认管理的是 Spring Security 5.8.x。
- 数据库:为了演示轻量,使用 H2 内存数据库,这样不需要额外安装 MySQL。
- 构建工具:Maven 3.6+。
- IDE:IntelliJ IDEA 或 Eclipse 均可,本文示例不依赖 IDE 插件。
- 包管理:使用 Maven 的
spring-boot-starter-parent统一管理依赖版本。
需要说明的是:这里的版本只是示例。真实项目里,版本需要根据公司的研发规范、基础组件兼容性来定。本文重点是“加盐、哈希、BCrypt 配置”的思路,即使版本不同,核心 API 仍然一致。
项目最终结构如下:
salt-demo ├── pom.xml ├── src/main/java/com/example/saltdemo/ │ ├── SaltDemoApplication.java │ ├── config/SecurityConfig.java │ ├── controller/AuthController.java │ └── service/UserService.java ├── src/main/resources/ │ ├── application.yml │ └── schema.sql └── src/test/java/com/example/saltdemo/ └── PasswordEncoderTest.java3. 核心原理拆解
3.1 PasswordEncoder 接口
在 Spring Security 中,密码处理的核心接口是PasswordEncoder。它定义了三个关键方法:
String encode(CharSequence rawPassword):接收明文密码,返回编码后的字符串。boolean matches(CharSequence rawPassword, String encodedPassword):校验明文密码与编码后的字符串是否匹配。boolean upgradeEncoding(String encodedPassword):判断是否需要对编码后的字符串进行再次升级,一般用于旧哈希迁移。
有了这个接口,业务代码不需要关心底层是 BCrypt、Argon2 还是自定义算法。项目里通常定义一个PasswordEncoder的 Bean,然后注入到 Service 层使用。
最简单的用法是这样:
PasswordEncoder encoder = new BCryptPasswordEncoder(); String hash = encoder.encode("123456"); boolean match = encoder.matches("123456", hash);注意:matches中传入的第二个参数,必须是encode方法生成出来的完整字符串,不能是数据库里的原始密码字段,也不能是自己拼接的盐。
3.2 BCryptPasswordEncoder 关键参数
BCryptPasswordEncoder是 Spring Security 对 BCrypt 算法的一个封装,使用时一般只需要关注两个点:
strength:迭代参数,也叫 cost,默认值是10,表示进行2^10轮迭代。合法范围一般是4 ~ 31。数值越大,计算越慢,安全性越高,但用户体验和 CPU 开销也会增加。SecureRandom:随机源,用于生成盐。默认使用SecureRandom,安全性足够高。
构造方式:
// 使用默认强度 10 PasswordEncoder encoder = new BCryptPasswordEncoder(); // 指定强度为 12 PasswordEncoder encoder = new BCryptPasswordEncoder(12);有一点需要提前说明:BCrypt 算法对输入密码长度有限制,通常只取密码的前 72 字节。如果密码过长,部分版本会直接报错,部分版本可能会截断。生产环境建议在注册接口加一个密码长度限制,比如最长 32 或 64 个字符,避免触发这个边界问题。
生成的字符串格式大约是:
$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy拆解一下:
$2a$ -> BCrypt 算法版本号 $10$ -> cost,即迭代参数 后面 22 个字符左右是盐,剩余部分是哈希值所以 BCrypt 的盐是“内嵌”在最终哈希值里的,不需要单独在数据库里增加一个salt字段,这就是它比“自己拼接盐”更省心的原因。
3.3 DelegatingPasswordEncoder 与 {bcrypt} 前缀
Spring Security 5 之后,默认推荐使用PasswordEncoderFactories.createDelegatingPasswordEncoder()来创建密码编码器。它会返回一个DelegatingPasswordEncoder,内部代理多种编码器,并根据哈希值的前缀自动选择对应的算法。
例如:
{bcrypt}$2a$10$... {noop}123456这种设计方便旧系统平滑迁移。比如旧的{noop}表示明文密码,{md5}表示 MD5 哈希,都可以在同一个系统中暂时共存,再逐步替换为{bcrypt}。
但很多新手在练习时,会直接把不带前缀的 BCrypt 哈希存在数据库里,然后让 Spring Security 自动帮你校验,结果出现下面这个经典报错:
There is no PasswordEncoder mapped for the id "null"原因是:Spring Security 默认从哈希字符串中解析{id}前缀,如果发现没有前缀,它就不知道应该用哪个编码器,于是直接报错。
解决方案有两种:
- 在写入门代码时,直接使用
new BCryptPasswordEncoder()作为PasswordEncoderBean,这样 Spring Security 就知道统一使用 BCrypt。 - 如果要支持多算法迁移,就用
DelegatingPasswordEncoder,并且确保数据库里的哈希值都带有{bcrypt}这样的前缀。
后面实战案例中,我们为了尽量简单,直接使用BCryptPasswordEncoder。这样代码清晰,也避免了不必要的报错。
3.4 先用代码看“盐”到底怎么工作的
在进入完整项目之前,先看一小段对比代码。下面分别用 MD5 和 BCrypt 对同一个密码编码两次:
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.util.DigestUtils; import java.nio.charset.StandardCharsets; public class SaltDemo { public static void main(String[] args) { String rawPassword = "123456"; // MD5:相同密码,哈希结果永远相同 String md5_1 = DigestUtils.md5DigestAsHex(rawPassword.getBytes(StandardCharsets.UTF_8)); String md5_2 = DigestUtils.md5DigestAsHex(rawPassword.getBytes(StandardCharsets.UTF_8)); System.out.println("MD5 第一次: " + md5_1); System.out.println("MD5 第二次: " + md5_2); System.out.println("MD5 结果是否一致: " + md5_1.equals(md5_2)); // BCrypt:相同密码,每次盐不同,所以哈希结果不同 PasswordEncoder encoder = new BCryptPasswordEncoder(); String bcrypt_1 = encoder.encode(rawPassword); String bcrypt_2 = encoder.encode(rawPassword); System.out.println("BCrypt 第一次: " + bcrypt_1); System.out.println("BCrypt 第二次: " + bcrypt_2); System.out.println("BCrypt 结果是否一致: " + bcrypt_1.equals(bcrypt_2)); System.out.println("第一次能匹配: " + encoder.matches(rawPassword, bcrypt_1)); System.out.println("第二次能匹配: " + encoder.matches(rawPassword, bcrypt_2)); System.out.println("错误密码能匹配: " + encoder.matches("wrong", bcrypt_1)); } }这段代码不需要 Web 环境,直接运行就可以看到效果。输出中你会发现:MD5 两次结果相同,而 BCrypt 两次结果完全不同,但matches都能通过。“同一个密码生成不同哈希”并不是 bug,而是加盐设计的核心优势。
4. 完整实战案例
接下来我们落地一个最简用户注册登录功能。这是一个非常典型的场景:用户注册时把密码用 BCrypt 哈希后入库,用户登录时用matches校验。
4.1 创建项目并配置 pom.xml
新建一个 Maven 项目,在pom.xml中引入以下依赖:
<?xml version="1.0" encoding="UTF-8"?> <project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <groupId>com.example</groupId> <artifactId>salt-demo</artifactId> <version>1.0.0-SNAPSHOT</version> <properties> <java.version>11</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build> </project>如果使用 Spring Boot 3.x,只需要把父版本改为 3.x,并把javax相关依赖换成jakarta即可,核心编码逻辑不变。
4.2 配置 application.yml 与初始化表
在src/main/resources/application.yml中配置 H2 数据源:
spring: datasource: url: jdbc:h2:mem:saltdb;DB_CLOSE_DELAY=-1 driver-class-name: org.h2.Driver username: sa password: "" sql: init: mode: always server: port: 8080然后创建一个src/main/resources/schema.sql,项目启动时会自动执行建表语句:
CREATE TABLE IF NOT EXISTS sys_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password_hash VARCHAR(100) NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP );这里把字段命名为password_hash,是为了提醒自己和后来的人:这个字段存的不是明文密码,而是密码哈希值。数据库字段类型建议给VARCHAR(100),因为 BCrypt 结果一般是 60 个字符,加上未来可能的算法前缀,或者迁移其他哈希算法,留足空间更稳妥。
4.3 编写 SecurityConfig
创建一个配置类src/main/java/com/example/saltdemo/config/SecurityConfig.java:
package com.example.saltdemo.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/**").permitAll() .anyRequest().authenticated() ); return http.build(); } }这里有两处要说明:
- 我们演示的是“接口注册 + 登录”的 REST API 场景,这种无状态接口通常不依赖浏览器 Cookie,所以先关闭 CSRF。如果业务是基于 Session 的浏览器表单登录,CSRF 必须保留。
requestMatchers("/api/**").permitAll()表示放开所有以/api/开头的接口,方便我们用curl测试。真实生产项目中,注册接口可以放开,但登录后的用户信息接口必须走认证鉴权逻辑,不能全部放行。
4.4 编写 UserService
创建一个 Service 类src/main/java/com/example/saltdemo/service/UserService.java,负责注册和登录逻辑:
package com.example.saltdemo.service; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import java.util.List; import java.util.Map; @Service public class UserService { private final JdbcTemplate jdbcTemplate; private final PasswordEncoder passwordEncoder; public UserService(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { this.jdbcTemplate = jdbcTemplate; this.passwordEncoder = passwordEncoder; } public void register(String username, String rawPassword) { if (username == null || username.trim().isEmpty()) { throw new IllegalArgumentException("用户名不能为空"); } if (rawPassword == null || rawPassword.length() < 6) { throw new IllegalArgumentException("密码长度不能少于 6 位"); } String uname = username.trim(); Integer count = jdbcTemplate.queryForObject( "SELECT COUNT(*) FROM sys_user WHERE username = ?", Integer.class, uname ); if (count != null && count > 0) { throw new IllegalStateException("用户名已存在"); } String encodedPassword = passwordEncoder.encode(rawPassword); jdbcTemplate.update( "INSERT INTO sys_user(username, password_hash) VALUES (?, ?)", uname, encodedPassword ); } public boolean login(String username, String rawPassword) { if (username == null || rawPassword == null) { return false; } String uname = username.trim(); List<Map<String, Object>> users = jdbcTemplate.queryForList( "SELECT password_hash FROM sys_user WHERE username = ?", uname ); if (users.isEmpty()) { return false; } String storedHash = (String) users.get(0).get("password_hash"); return passwordEncoder.matches(rawPassword, storedHash); } }这里体现了最重要的两个动作:
passwordEncoder.encode(rawPassword):注册时生成 BCrypt 哈希。passwordEncoder.matches(rawPassword, storedHash):登录时校验密码。
注意,登录失败时我们没有区分“用户不存在”和“密码错误”,而是统一返回失败。这是为了避免攻击者通过接口差异枚举出系统中存在的用户名。
4.5 编写 AuthController
创建一个 Controller 类src/main/java/com/example/saltdemo/controller/AuthController.java:
package com.example.saltdemo.controller; import com.example.saltdemo.service.UserService; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import java.util.Map; @RestController @RequestMapping("/api") public class AuthController { private final UserService userService; public AuthController(UserService userService) { this.userService = userService; } @PostMapping("/register") public ResponseEntity<?> register(@RequestBody Map<String, String> body) { String username = body.get("username"); String password = body.get("password"); try { userService.register(username, password); return ResponseEntity.ok(Map.of("code", 0, "message", "注册成功")); } catch (IllegalArgumentException | IllegalStateException e) { return ResponseEntity.badRequest().body(Map.of("code", 1, "message", e.getMessage())); } } @PostMapping("/login") public ResponseEntity<?> login(@RequestBody Map<String, String> body) { String username = body.get("username"); String password = body.get("password"); boolean success = userService.login(username, password); if (success) { return ResponseEntity.ok(Map.of("code", 0, "message", "登录成功")); } return ResponseEntity.status(401).body(Map.of("code", 1, "message", "用户名或密码错误")); } }这里我故意没有使用复杂的 DTO 类,而是用Map<String, String>接收 JSON,目的是减少文件数量,让核心逻辑更突出。真实项目中建议定义RegisterRequest、LoginRequest这样的 DTO,并做参数校验。
最后补上启动类src/main/java/com/example/saltdemo/SaltDemoApplication.java:
package com.example.saltdemo; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class SaltDemoApplication { public static void main(String[] args) { SpringApplication.run(SaltDemoApplication.class, args); } }4.6 运行与验证
在项目根目录执行:
mvn spring-boot:run启动成功后,直接用curl验证接口。先注册一个用户:
curl -X POST http://localhost:8080/api/register \ -H "Content-Type: application/json" \ -d '{"username": "zhangsan", "password": "abc123456"}'预期输出:
{"code":0,"message":"注册成功"}再调用登录接口:
curl -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username": "zhangsan", "password": "abc123456"}'预期输出:
{"code":0,"message":"登录成功"}如果用错误密码:
curl -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username": "zhangsan", "password": "wrong-password"}'预期返回 HTTP 401:
{"code":1,"message":"用户名或密码错误"}如果你想查看数据库里到底存了什么,可以把 H2 改为文件模式,或者用一个简单的查询接口临时打印。不过从教学角度,我更建议在测试代码里验证。
4.7 编写一个加盐行为测试
创建一个测试类src/test/java/com/example/saltdemo/PasswordEncoderTest.java:
package com.example.saltdemo; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.security.crypto.password.PasswordEncoder; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; @SpringBootTest public class PasswordEncoderTest { @Autowired private PasswordEncoder passwordEncoder; @Test void samePasswordShouldGenerateDifferentHashes() { String raw = "abc123456"; String hash1 = passwordEncoder.encode(raw); String hash2 = passwordEncoder.encode(raw); // 盐不同,哈希结果应该不同 assertNotEquals(hash1, hash2); // 但都能通过 matches 校验 assertTrue(passwordEncoder.matches(raw, hash1)); assertTrue(passwordEncoder.matches(raw, hash2)); assertFalse(passwordEncoder.matches("abc123457", hash1)); } }运行mvn test,如果测试通过,说明基本的加盐哈希逻辑没有问题。这个测试也适合作为后续重构的回归测试,防止有人把PasswordEncoder换成错误实现。
5. 常见问题与排查思路
在实际开发中,密码加盐这一块最容易出问题的不是概念,而是配置和数据处理上的细节。下面整理了几个高频问题。
5.1 There is no PasswordEncoder mapped for the id "null"
这个报错在 Spring Security 5 之后非常常见。原因是 Spring Security 默认使用DelegatingPasswordEncoder,它会根据存储哈希值的前缀来决定使用哪种编码器。如果你的数据库里存的是不带前缀的原始 BCrypt 字符串,比如:
$2a$10$N9qo8uLOickgx2ZMRZoMye...Spring Security 不知道这个字符串对应的是什么算法,所以报错。
解决思路:
- 如果你确定系统里只用 BCrypt,直接把
PasswordEncoderBean 定义为new BCryptPasswordEncoder()。 - 如果旧系统有多种密码格式,需要保留
DelegatingPasswordEncoder,并且给数据库里的哈希加上对应前缀,比如{bcrypt}或{noop}。
5.2 matches 永远返回 false
matches返回 false,最常见的原因有几个方向:
- 注册和登录时用的密码不一致,比如前端对密码做了
trim处理,而后端没做统一。 - 数据库字段长度不够,
password_hash被截断,导致matches拿到的不是完整哈希值。 - 查询时取错字段,比如取成了明文密码字段。
- 自己手写了一套“盐拼接”逻辑,登录时盐和注册时不一致,导致哈希结果不一致。
排查思路:先打印出数据库里password_hash的完整值,再确认它是encode方法的输出,然后用matches单独跑一次。如果单独跑能匹配上,说明问题出在数据读取或传入的参数上。
5.3 同一个密码每次生成结果不一样,认为是 bug
这是新手最容易误判的问题。BCrypt 每次encode都会生成新的随机盐,所以同一个密码两次结果不一样是正常现象。不要试图把两次结果改成一致,也不要因此去掉盐。校验是否一致的唯一方式是通过matches方法,而不是直接比较字符串。
5.4 密码超过 72 字节报错或部分版本截断
BCrypt 算法只使用输入密码的前 72 字节。如果你的业务允许超长密码,可能会遇到部分版本抛出异常、部分版本静默截断的情况。
最稳妥的解决方案是在注册接口做长度限制。比如限制密码长度不超过 32 或 64 个字符,这样既不会触发 BCrypt 的边界问题,也能避免超长密码带来的性能开销。
如果确实需要支持超长密码,可以考虑先对密码做一次 SHA-256 预哈希,再把哈希结果交给 BCrypt。但这个过程要非常谨慎,因为它会影响老密码的迁移策略,千万不要在生产环境直接切换,必须先在测试环境验证完整流程。
5.5 登录接口响应特别慢
BCrypt 默认cost=10,单次计算可能消耗几十毫秒。如果系统并发量高,登录接口变慢是正常现象,因为慢哈希本身就是为了拖慢攻击者。
优化方向:
- 不要把
cost调得过高,安全需要平衡。一般10~12足够。 - 对登录接口做限流,防止攻击者通过并发请求消耗服务器资源。
- 考虑多级缓存策略,但要谨慎,不要把密码哈希缓存在外部系统,避免扩大泄露面。
5.6 旧系统 MD5 密码如何平滑迁移
如果老系统数据库里存的是 MD5 哈希,不能简单地把所有用户密码重设为 BCrypt,因为哈希不可逆,无法知道原密码。
常见迁移策略是:用户登录时,先用matches判断新旧算法。如果发现用户仍是 MD5 哈希,就在校验成功后,用 BCrypt 重新生成哈希并更新数据库。这样可以逐步把全量用户迁移到 BCrypt,用户无感知。在 Spring Security 中,DelegatingPasswordEncoder就是为此设计的。
下面用表格汇总一下这些常见问题:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| There is no PasswordEncoder mapped for the id "null" | 哈希值缺少{id}前缀,且默认编码器无法识别 | 直接使用 BCryptPasswordEncoder 或补前缀 |
| matches 始终 false | 数据被截断、取错字段、明文前后端处理不一致 | 核对存储值和参数,单独跑 matches 验证 |
| 同样密码两次结果不同 | BCrypt 每次生成随机盐 | 不是 bug,用 matches 校验 |
| 超长密码报错或校验失败 | BCrypt 只处理前 72 字节 | 限制输入长度,或预哈希后再 BCrypt |
| 登录接口慢 | BCrypt 故意慢哈希 | 合理设置 cost,给登录接口做限流 |
| 旧 MD5 密码无法登录 | 算法变更,旧哈希无法匹配 | 采用登录时渐进式迁移策略 |
6. 最佳实践与工程建议
了解了代码和基本排查之后,再来看一些实际项目中更值得注意的原则。
6.1 永远不要自己实现密码哈希算法
“把密码和盐拼起来再做 SHA-256”确实是一种加盐方案,但自己实现容易踩很多坑,比如盐的生成不够随机、拼接顺序不一致、迭代次数不足等。专业的密码哈希算法(BCrypt、scrypt、Argon2)已经经过多年的安全设计,并且会自己处理盐、迭代参数和结果格式。直接用成熟库的方案,能让代码的可维护性和安全性都更好。
6.2 正确选择 cost 值
BCrypt 的cost值越高,单次计算越慢,CPU 消耗越大。这个参数需要根据业务并发和服务器性能来调整。
- 默认
10对大多数系统已经够用。 - 安全要求更高的系统可以尝试
12,但一定要做压测,观察登录接口在高峰期的表现。 - 不建议盲目调到
15以上,否则一次注册或登录请求可能耗时数百毫秒,对用户体验影响很大。
6.3 数据库表字段设计要留足空间
建议把密码字段命名为password_hash,而不是password。这样做的好处是语义清晰,别人接手代码时不会误以为里面存的是明文。字段长度建议VARCHAR(100),不要用VARCHAR(32),因为 BCrypt 结果通常是 60 个字符,将来如果加算法前缀,或者换成其他哈希算法,长度也足够。
6.4 接口层的密码传输必须走 HTTPS
BCrypt 解决的是“数据被拖库后密码不被还原”的问题,但它不能解决“密码在网络上被截获”的问题。如果注册和登录