bcrypt 为什么适合存密码?$2b$10$ 的结构、cost 轮数与 72 字节上限
bcrypt 能用来存密码,靠的不是「不可逆」——MD5 也不可逆。它靠的是 MD5 没有的两样东西:自带随机盐,让预先算好的彩虹表失效;cost 参数,让每一次计算都要花一笔真实的时间,而且这笔开销你可以调。
打开 bcrypt 加密工具,输入 hello,轮数留默认的 10,结果是 60 个字符:
$2b$10$RFt4zyjwVVAOPb7I9Q./uuGp6.KjY2De4GYDxjxK4QWEzp7gcFOo2
这串字符不是随机噪声,是四段拼起来的:算法标识、成本、盐、摘要。看懂这四段,cost 怎么设、为什么同一个密码两次结果不同、为什么超长密码后半段无效,都能自己推出来。

60 个字符分成四段:成本和盐都写在结果里
| 段位 | 内容 | 实测值 | 长度 |
|---|---|---|---|
| 第 1 段 | 版本标识,bcrypt 的格式版本 | $2b$ |
4 |
| 第 2 段 | cost 轮数,实际迭代 2 的 cost 次方 | 10 |
2 |
| 第 3 段 | 随机盐,16 字节用 bcrypt 自己的 Base64 变体编码 | RFt4zyjwVVAOPb7I9Q./uu |
22 |
| 第 4 段 | 摘要 | Gp6.KjY2De4GYDxjxK4QWEzp7gcFOo2 |
31 |
四段合计 59 个字符(4 + 2 + 22 + 31),再加上 10 和盐之间的那一个 $,整串正好 60 个字符、一共含 3 个 $。第一段的 $2b$ 是当前官方的 bcrypt 版本标识;第二段的 10 表示 1024 轮密钥扩展。
这段结构来自 1999 年 Provos 和 Mazières 的论文《A Future-Adaptable Password Scheme》:bcrypt 用 128 位盐,把成本、盐和加密结果一起输出成字符串。论文里给密码函数定的三条标准是——抗第二原像、盐空间大到无法预计算、成本可调。这三点正是它和 MD5 的分水岭。
实际意义很直接:盐不需要你另外存。用 MD5 加盐时,你得在数据库里加一列放盐;bcrypt 把盐塞进了这 60 个字符,取出来就能用。成本也一样,校验时不用问「当初设了多少轮」,字符串自己带着。
同一密码两次哈希不同,但两次都能验过
实测连续两次对 hello 做 10 轮 bcrypt,结果完全不同:
| 运行 | 盐(22 字符) | 摘要(31 字符) |
|---|---|---|
| 第 1 次 | RFt4zyjwVVAOPb7I9Q./uu |
Gp6.KjY2De4GYDxjxK4QWEzp7gcFOo2 |
| 第 2 次 | Mt87tPCp8.CEJ7UDSEFqCu |
mIbibU81vtQ9UFJFKI.tcvKVmkbOiBm |
两串哈希拿去比对,都返回「匹配」;把待比对字符串换成大写的 Hello,立刻变成「不匹配」。
所以校验逻辑不是「比较两个哈希字符串是否相等」,而是四步:
- 从库里取出那 60 个字符;
- 取前 29 个字符(
$2b$10$加 22 字符盐),这里已经包含了成本和盐; - 用这段前缀,把用户这次输入的明文重新算一遍;
- 比较新结果的后 31 个字符与库里的是否一致。
工具内的比对函数就是这么做的:它先检查传入的哈希长度必须是 60,不满足直接返回 false,然后把前 29 位当作盐和成本重新计算,再比后 31 位。实测三种「格式不对」的输入,全都只得到「不匹配」,不报错:
| 传入的哈希 | 比对结果 |
|---|---|
| hello 的哈希截掉最后 1 位(59 字符) | 不匹配 |
| 32 字符的 MD5 值 | 不匹配 |
| 60 个字符的随机串 xxxxxxxxxxxx… | 不匹配 |
这就是排查登录失败时最容易走偏的地方:59 位截断、把 MD5 值塞进 bcrypt 校验、前缀写错,表现全都一样——不匹配。先数位数,再查前缀。

cost 每加 1,耗时翻倍:1.7ms 到 1322ms
cost 不是「轮数」的字面意思,而是指数。passlib 文档写得很明确:实际迭代次数是 2 的 cost 次方,cost 加 1 耗时翻倍。本机 Chromium 里同一台机器实测(每档 3 次):
| cost | 三次耗时(ms) | 中位数(ms) | 大约是上一档 |
|---|---|---|---|
| 4 | 1.7 / 1.7 / 3.0 | 1.7 | — |
| 6 | 7.9 / 5.5 / 6.7 | 6.7 | 3.9× |
| 8 | 21.9 / 30.7 / 21.8 | 21.9 | 3.3× |
| 9 | 41.7 / 43.8 / 42.2 | 42.2 | 1.9× |
| 10 | 82.3 / 82.3 / 84.0 | 82.3 | 2.0× |
| 11 | 164.9 / 168.1 / 162.2 | 164.9 | 2.0× |
| 12 | 324.0 / 331.3 / 323.7 | 324.0 | 2.0× |
| 13 | 667.9 / 644.0 / 660.6 | 660.6 | 2.0× |
| 14 | 1347.9 / 1295.1 / 1322.1 | 1322.1 | 2.0× |
从 10 轮往上,每加一档都稳定翻倍;表里 6 到 8 跨了两档,所以是 3.3 倍而不是 2 倍。这条曲线决定了怎么选:OWASP 给的下限是 10;更实用的标定方法是拿你线上最慢的那台机器测一次,让单次校验落在 100 毫秒上下。本机 10 轮 82 毫秒、12 轮 324 毫秒,对登录接口来说 12 轮已经开始影响响应时间了。手机 CPU 比服务器慢好几倍,标定时要按最慢的设备算。
反过来说,这个「慢」正是防护本身:攻击者拿到库以后是离线爆破,每猜一个密码都要付同样的 82 毫秒,成本被直接放大几万倍。
轮数填 0 会得到 10 轮,填 1 只会得到 4 轮
输入框写着「范围 0-100」,但真正生效的范围不是这个。实测:
| 输入框里的值 | 结果里的成本段 | 实际轮数 |
|---|---|---|
| 0 | $2b$10$ |
10 |
| 1 | $2b$04$ |
4 |
| 3 | $2b$04$ |
4 |
| 10 | $2b$10$ |
10 |
原因在页面加载的 bcryptjs 实现里:生成盐时先执行 rounds = rounds || 10,所以 0 被当成「没填」,回落到默认 10;接着 rounds < 4 ? 4 : ... 把小值抬到 4。同一段代码里还有上限 rounds > 31 → 31,意思是填 100 也会按 31 轮算——那是 2^31 轮,在浏览器里会长时间无响应(这一条是按源码判断,没有实测,因为真的执行下去页面会卡死,不建议试)。
结论:输入框的 0–100 只是控件范围,bcrypt 有效成本是 4–31。别以为填 100 更安全,它只会让你的登录接口卡住。

72 字节上限:第 73 个字节开始,密码等于没设
这是 bcrypt 最容易被忽略的一条硬限制:它只把密码的前 72 个字节送进算法。passlib 文档写明默认会静默截断超过 72 字节的密码;OWASP 的密码存储速查表也直接建议把用户密码长度上限设为 72 字节,因为「大多数实现」的上限就是 72 字节。
先看 ASCII 的实测。以 pass: 加 67 个 a(共 72 字节)为哈希对象:
| 拿去比对的字符串 | 字节数 | 结果 |
|---|---|---|
| pass: + 61 个 a | 66 | 不匹配 |
| 另一组 72 字节,末位换成 Y | 72 | 不匹配 |
| 原字符串本身 | 72 | 匹配 |
| 原字符串末尾再加一个 X | 73 | 匹配 |
| 72 个 a + 28 个 b | 100 | 匹配(另一次运行) |
73 字节和 100 字节都能通过 72 字节哈希的校验,说明第 73 个字节起根本没有参与运算。
中文会更早触到上限:UTF-8 下一个汉字占 3 个字节,24 个汉字正好 72 字节。实测以「宝塔面板」加 20 个「中」(24 个汉字,72 字节)为哈希对象:
- 23 个汉字(少一个中)→ 不匹配;
- 另一组 24 个汉字、末位换成「文」→ 不匹配;
- 25 个汉字,末尾多一个「尾」→ 匹配,第 25 个汉字整字被丢弃。

实际影响有两层。一层是「用户以为设了长密码,其实只有前 72 字节有效」;另一层是「等价密码变多」——只知道前 72 字节的人也能登录。处理办法有两个方向:
- 在应用层直接把密码长度上限卡在 72 字节(OWASP 推荐的做法);
- 先 pre-hash 再交给 bcrypt。OWASP 提醒这条路有坑:哈希输出里可能出现 NUL 字节,而 bcrypt 原本按 NUL 结尾处理字符串,遇到 0 字节就截断;同时要防 password shucking。它给出的做法是
bcrypt(base64(hmac-sha384(密码, pepper))),即先做 Base64 编码、再用带密钥的 HMAC。别随手写成bcrypt(sha256(密码))。
为什么不用 MD5:把「一次计算」变贵
本机实测(同一台机器,不同运行时,只看量级):
| 算法 | 输入 | 速度 |
|---|---|---|
| MD5(Node 单线程) | 16 字节 | 50 万次 966ms,约 51.7 万次/秒 |
| bcrypt cost 10(Chromium) | hello | 82.3ms 一次,约 12 次/秒 |
两者差了大约 4.3 万倍。这个差距在正常登录时完全感觉不到,但在拖库之后的离线爆破里,就是「几小时」和「几百年」的区别。而且 bcrypt 还留了一个旋钮:硬件变快了,把 cost 加 1,成本立刻翻倍;MD5 没有这个旋钮,只能整个换掉。
bcrypt 也不是终点。OWASP 现在的排序是:首选 Argon2id(最低 19 MiB 内存、2 次迭代、1 并行度),其次 scrypt,bcrypt 归为「只在老系统、拿不到前两者时用」。但即便是 legacy 定位,bcrypt 也远好过 MD5 或裸 SHA-256。
老系统迁移不用停机:加一个新字段存 bcrypt 或 Argon2id 的结果,登录时先用旧算法校验,通过的那一刻立刻用明文重新算一份写进新字段,存量数据会随用户登录逐步迁完。新用户注册时的密码,可以用 密码生成器 生成足够随机的初始密码。
用这个工具时的三个注意点
第一,生产密码不要贴进任何在线页面。 页面写明「基于 bcryptjs 在浏览器本地生成 bcrypt 哈希,可自定义 salt 轮数(0-100)……所有计算在浏览器本地完成,不会上传到服务器」,但这个结论只能从代码侧验证。真实用户密码应该在服务端用库生成:PHP 用 password_hash(),Node 用 bcrypt,Python 用 bcrypt。
第二,注意前缀和字段长度。 工具输出 $2b$,PHP 的 password_hash() 输出 $2y$(PHP 手册写明),算法相同、标识不同,对接前确认对方校验函数认哪个。数据库字段别只留 60 个字符,PHP 手册建议留 255,因为默认算法会随版本升级变化。
第三,比对区只给结论,不给原因。 长度不是 60、前缀不对、密码确实输错,全都显示「不匹配」。用它排查时,先自己数一遍哈希位数、确认没有从 Excel 或日志里带进空格。
一句话结论
bcrypt 适合存密码,是因为它把「每次验证都要付一笔钱」写进了算法参数,并且把盐和成本都塞进那 60 个字符里;代价是 72 字节上限,以及要随硬件发展不断上调的 cost。存密码用它或者 Argon2id,别再用 MD5。
本文数字来自 2026-11-11 11:41 与 11:43 的浏览器实测,截图与对应数字同源。
常见问题
bcrypt 能解密回原密码吗?
不能,bcrypt 是单向哈希。校验时不是「解密后对比」,而是从库里那 60 个字符里取出成本(第 5–6 位那两个数字)和 22 字符盐,把用户这次输入的明文重新算一遍,再比较后 31 个字符是否一致。所以库泄露了也拿不到明文,只能一个个猜。
同一个密码生成两次 bcrypt 哈希,为什么结果不一样?
因为每次都会生成一段新的随机盐,盐不同结果就不同。实测 hello 在 10 轮下连续两次得到 $2b$10$RFt4zyjwVVAOPb7I9Q./uuGp6.KjY2De4GYDxjxK4QWEzp7gcFOo2 和 $2b$10$Mt87tPCp8.CEJ7UDSEFqCumIbibU81vtQ9UFJFKI.tcvKVmkbOiBm,盐和摘要都不一样,但两个都能和 hello 比对成功。这也是不能用「哈希字符串相等」来判断密码对不对的原因。
bcrypt 的 cost 设多少合适?
OWASP 给的下限是 10。实际标定方法是:在你最慢的那台服务器上测一次校验耗时,让它落在 100 毫秒上下。本机浏览器实测 cost 10 是 82 毫秒、12 是 324 毫秒、14 是 1322 毫秒,cost 每加 1 耗时翻倍,所以不要为了「更安全」随手往上调,14 轮会让登录接口明显变慢。
为什么长度超过 72 字节的密码,后面的字符等于没设?
bcrypt 只把密码的前 72 个字节送进算法,passlib 文档写明默认会静默截断,OWASP 也建议把密码上限设为 72 字节。实测:先哈希 72 字节的 pass:+67 个 a,再用 72 字节加一个 X(共 73 字节)去比对,结果是「匹配」——第 73 个字节根本没参与。中文按 UTF-8 一个字 3 字节,24 个汉字正好 72 字节,第 25 个汉字整字被丢弃。
$2a$、$2b$、$2y$ 三种前缀有什么区别?
都是 bcrypt,只是版本标识不同:$2b$ 是当前官方版本,工具输出的就是它;$2a$ 是较早的版本,个别老实现只认这个前缀;$2y$ 是 crypt_blowfish 实现的写法,除名字外与 2b 一致(passlib 文档的说明)。PHP 的 password_hash() 生成的是 $2y$,对接时要确认对方的校验函数认哪个前缀。
新项目该选 bcrypt 还是 Argon2id?
OWASP 现在的首选是 Argon2id(最低 19 MiB 内存、2 次迭代、1 并行度),其次 scrypt,bcrypt 被归为「只在老系统、装不了 Argon2 和 scrypt 时使用」。但 bcrypt 仍然远好过 MD5 和裸 SHA-256:它有盐、有可调成本,是成熟可用的选择。真正要避免的是拿 MD5 或裸 SHA 存密码。
数据库里存 bcrypt 哈希,字段要留多长?
bcrypt 结果固定 60 个字符,但别只留 60。PHP 手册建议 password_hash() 的结果列留到 255 字节,因为默认算法会随版本升级更换、输出长度可能变长。留 60 会导致将来换到 Argon2id 时截断,登录全部失败。