JsonMind
安全与认证12 分钟阅读更新于 2026年11月9日

密码哈希:为什么不能用 SHA-256,bcrypt 又该怎么配

从攻击者视角解释密码存储,比较 bcrypt、Argon2id 和 scrypt,说明盐、pepper、成本因子和迁移旧哈希的正确做法。

JM作者:JsonMind Engineering最后复核: 2026年11月9日依据权威规范与引用来源复核。
快速答案

密码必须使用专门的慢哈希算法,例如 Argon2id、scrypt 或 bcrypt。SHA-256 设计目标是快,GPU 每秒可以尝试数十亿次。不要存密码的 SHA-256 摘要,也不要用可逆加密保存密码。

核心要点
  • SHA-256、MD5 等通用哈希太快,不适合密码存储。
  • bcrypt 自带盐和成本因子;Argon2id 还增加内存硬度,是新系统首选。
  • 每个密码使用唯一盐,校验时使用常量时间比较。
  • 密码哈希不是加密,任何时候都不应该能还原原始密码。
  • 还要配合限流、MFA、泄露监控和独立存储的 pepper。

密码存储是少数“快”反而是缺陷的场景。一个毫秒级完成的哈希,对登录接口很方便,对拿到数据库的攻击者同样方便。

这篇文章从威胁模型出发,比较常见算法,并说明哈希之外还需要哪些控制。

威胁模型

假设攻击者已经拿到用户表。他能做什么?

  • 存明文:所有密码直接泄露。
  • 存 SHA-256:GPU 每秒可以尝试数十亿个候选密码。
  • 存慢速加盐哈希:每次猜测都需要显著的时间和内存。
  • 加密密码:密钥泄露后全部可解密。

差距不是一个小常数。具体速率取决于硬件和实现,但数量级才是关键:

存储形式离线猜测速度(约)实际含义
明文或可逆加密无上限根本不需要破解
MD5 或 SHA-1数百亿次/秒常见密码空间很快被穷举
SHA-256数十亿到数百亿次/秒一张 GPU 每秒能试很长的候选列表
bcrypt,cost 12数千次/秒每次猜测都要花 CPU 和内存带宽
Argon2id,19 MiB,t=2数百到数千次/秒每次猜测都要分配内存,并行度受限

这张表是规划用的,不是 benchmark。重点是:快哈希把“拿到数据库”变成几个小时的离线工作,慢哈希把它变成长期、昂贵的工作。攻击者每秒能试 100 亿个 SHA-256 候选时,一个符合常见模式的 8 位密码很快就会被攻破;同一个数据库换成 cost 12 的 bcrypt,速率会下降六到七个数量级。

密码哈希不能阻止泄露,但能限制泄露后的损失。

为什么 SHA-256 是错误工具

SHA-256 是为完整性和速度设计的。现代 GPU 每秒可以计算海量 SHA-256。加盐可以防预计算彩虹表,但不能阻止针对性的暴力猜测。

SHA-256: 快、确定性、没有内置盐
bcrypt:  设计上就慢,自带盐和成本因子
Argon2:  慢且内存硬,参数可调

用 SHA-256 存密码,就像用一把“试够次数就能瞬间打开”的锁。

一个具体向量能让问题直观看出来。SHA-256("password") 是 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8,而一张 GPU 每秒可以对这个候选算出数十亿次。加盐改变了输入,但没有改变速度:SHA-256(salt + password) 仍然只多一次压缩函数。hashcat 里裸 SHA-256 是模式 1400,加盐 SHA-256 是模式 1420,两者都针对吞吐优化;bcrypt 是模式 3200,每次猜测慢好几个数量级。针对某个账号,攻击者不需要彩虹表,只需要字典、规则集和几个小时。

pepper 也救不了快哈希。pepper 和数据库放一起,攻击者直接拿到;放在别处,它只提高“第一次猜测”的成本,一旦泄露,后续每次猜测的速度不变。带 pepper 的 HMAC-SHA-256 仍然是快函数。

SHA-256 在别的场景是正确的:文件完整性、内容寻址、文档签名、从高熵密钥派生。规则针对的是输入,不是算法本身:密码是低熵、可猜的,所以函数必须慢;128 位随机 API Key 没有可猜结构,用快哈希可以接受,前提是常量时间比较,并且永远不记录 Key 本身。

bcrypt 怎么工作

bcrypt 从密码、盐和成本因子派生哈希。成本因子是指数的,每加 1,工作量翻倍。

$2b$12$Fh8sK2...   <- 算法、成本、盐、哈希

关键特性:

  • 每个密码生成唯一盐。
  • 成本因子可以随硬件升级而提高。
  • 输出里包含算法、成本和盐,校验时只需要存储字符串。
  • 输入限制为 72 字节;更长的密码需要预哈希或明确拒绝。

校验必须使用库提供的常量时间比较:

import bcrypt from "bcryptjs";

const hash = await bcrypt.hash(password, 12);
const valid = await bcrypt.compare(candidate, hash);

不要自己用 === 比较哈希字符串。库的比较函数设计上会避免时序泄露。

存储串是自描述的:$2b$12$ 是版本和成本,接下来 22 个字符是 bcrypt 专用 Base64 编码的 128 位盐,最后 31 个字符是哈希,完整记录 60 个字符。$2a$ 和 $2y$ 出现在旧记录或 PHP 生成的记录里,多数库三种都接受,但跨语言迁移时应该拿一个已知哈希实际验证,而不是假设兼容。成本是指数级的:cost 10 是 1,024 次迭代,cost 12 是 4,096 次,每加 1 工作量翻倍,校验耗时也大致翻倍。

72 字节限制是真实的正确性 bug,不是理论脚注。用 bcryptjs 时,把 "a".repeat(72) + "X" 哈希,再用 "a".repeat(72) 去比较会返回 true,因为末尾的 X 被静默截断了。用户选了一个 73 字节的长密码,后来只改了最后一个字符,仍然能用旧值登录;两个不同的长密码也可能共享同一个哈希。修复方式有两种:先用 SHA-256 或 HMAC-SHA-256 预哈希,把摘要用 Base64 或十六进制编码后交给 bcrypt;或者明确拒绝超过 72 字节的输入并给出清晰提示。预哈希也有坑:不要把含 NUL 字节的原始摘要交给把 NUL 当终止符的实现,Base64 或十六进制可以避开。方案要写进文档,因为以后一改,所有存量哈希都会失效。

成本选择是延迟预算问题。要在生产硬件、峰值并发下测量真实的 compare 耗时,而不是在笔记本上测。如果 cost 12 要 250 毫秒,登录接口每个实例允许 40 个并发尝试,CPU 会在数据库之前先饱和;排队和限流属于设计的一部分。硬件升级后要重新 benchmark,成本已经写在哈希里,旧记录可以在登录时升级。

Argon2id 怎么工作

Argon2id 是内存硬密码哈希函数,赢得过 Password Hashing Competition,OWASP 推荐新系统使用。

参数:

  • 内存:每次哈希使用多少 RAM。
  • 迭代次数:在内存上跑几遍。
  • 并行度:可以并行多少条通道。
import argon2 from "argon2";

const hash = await argon2.hash(password, {
  type: argon2.argon2id,
  memoryCost: 19456, // KiB
  timeCost: 2,
  parallelism: 1
});

const valid = await argon2.verify(hash, candidate);

内存硬度会提高 GPU 和 ASIC 攻击成本,因为每次猜测都需要大块内存。具体参数取决于硬件和延迟预算,应该实测,不要盲目复制。

存储格式是 PHC 字符串,例如 $argon2id$v=19$m=19456,t=2,p=1$c29tZXNhbHQ$...。参数就嵌在记录里,校验时直接从字符串读取,不需要再去查配置。OWASP 对 Argon2id 的最低建议是 19 MiB 内存、2 次迭代、1 条并行通道(m=19456,t=2,p=1);延迟预算允许时,64 MiB、3 次迭代更强。parallelism 是并行通道数,不是“分配几个 CPU 核”,调高会让多核攻击者也变快,除非实测过取舍,否则保持适度。

Argon2id 是混合模式:第一轮的前半段与数据无关(类似 Argon2i,抗侧信道),其余部分与数据相关(类似 Argon2d,抗时间-内存权衡)。这也是推荐 id 变体、而不是纯 i 或纯 d 的原因。

内存成本带来一个 bcrypt 没有的可用性风险。每次哈希 19 MiB,100 个并发登录就是约 1.9 GiB;一波撞库请求可能在限流生效之前就耗尽进程内存或触发 OOM killer。要用信号量或队列限制并发哈希数,队列满时返回 429 Too Many Requests 和 Retry-After,并监控分配失败。安全但一压就挂的密码哈希,不是成功的设计。

scrypt

scrypt 也是内存硬函数,生态支持广。没有 Argon2id 时是合理选择。参数包括 N(CPU/内存成本)、r(块大小)、p(并行度)。同样需要实测并记录参数。

内存占用大约是 128 * N * r 字节,N=2^17, r=8, p=1 需要约 128 MiB,这是 OWASP 对 scrypt 的最低建议,远高于大多数默认配置。Node.js 的 crypto.scrypt 默认 maxmem 是 32 MiB,直接传这组参数会抛 RangeError [ERR_CRYPTO_INVALID_SCRYPT_PARAMS]: Invalid scrypt params: ... memory limit exceeded。调高 maxmem 能解决眼前的报错,但会加剧并发和 OOM 问题,所以先把并发池设计好再调。$scrypt$ln=17,r=8,p=1$... 这样的 PHC 记录会把参数和哈希放在一起;如果你的库用了别的编码,要写进文档。

scrypt 比 Argon2 更早,也没有 Argon2id 的混合抗侧信道设计,但它被分析了很多年,在 Argon2 不可用或没有打包的环境里是站得住脚的选择。不要自己用 SHA-256 加循环发明 KDF,用经过评审的实现。

盐、pepper 和密钥

盐是每个密码唯一的,和哈希一起存储,用于防止相同密码产生相同哈希,并防预计算表。

pepper 是应用于所有密码的秘密值,存储在数据库之外,例如环境变量或 KMS。它提供纵深防御:只拿到数据库的攻击者得不到 pepper。pepper 不能替代慢哈希,轮换 pepper 时需要在登录时重新哈希。

存储值 = slow_hash(password + pepper, unique_salt)

不要把 pepper 和哈希存在同一个数据库里。

细节决定 pepper 到底有没有用。盐至少 16 字节,来自密码学安全随机数生成器,每个密码独立生成,和哈希一起存;用邮箱派生的确定性盐比全局常量好,但仍然允许针对已知用户列表做预计算。pepper 的作用方式:bcrypt 之前先算 HMAC-SHA-256(pepper, password);或者用 Argon2 专门的 secret 参数,它本来就为此设计,会混入计算且不会写进 PHC 字符串。如果只是把 pepper 当普通文本拼在前面(pepper + password),更容易犯长度扩展或编码错误;HMAC 或内置 secret 参数更干净。

轮换需要版本号。每条哈希记录存 pepper_version,过渡期同时保留当前和上一版 pepper,先试当前再试旧的,成功登录时重新哈希。没有双读支持的 pepper 轮换会让所有用户都收到“密码错误”,支持团队还无法把它和真实泄露区分开。动线上密钥之前,先用接近生产的数据在预发环境演练一遍。

校验和迁移

把算法和参数和哈希一起存储。校验时读取算法标识并分派:

type StoredPassword =
  | { algorithm: "bcrypt"; hash: string }
  | { algorithm: "argon2id"; hash: string }
  | { algorithm: "sha256-legacy"; hash: string };

用户登录成功时,如果存储的是旧算法或弱成本,立即用当前策略重新哈希并更新记录。这样可以在不强制重置密码的情况下完成迁移。

并发会让更新比看起来复杂。两个登录同时用旧哈希校验成功,各自用不同的盐算出新哈希,然后都写入;后写的赢,这没关系,因为两个新哈希都能验证同一个密码。更糟的竞态是改密请求落在“校验”和“重新哈希”之间:重新哈希会用旧密码覆盖刚设置的新密码。用条件更新,例如 UPDATE users SET password_hash = $new WHERE id = $id AND password_hash = $old;影响行数为 0 就放弃这次重哈希,等下次登录再试。多数库有 needsRehash 或 getRounds,把策略集中在一处,不要散落在各种比较里。

迁移救不了从不登录的用户。按算法统计存量旧哈希的数量并设截止日期;到期后对剩余账号强制重置,而不是让快哈希无限期留着。一个“临时”保留了三年 SHA-256 的列,就是长期负债。

校验路径本身要有测试:正确密码返回 true,错误密码返回 false,旧成本触发重哈希,未知算法安全失败而不是抛 500,73 字节密码的行为符合文档。

同样重要的运营控制

强哈希不能弥补薄弱的认证流程:

  • 按账号和 IP 限制登录尝试。
  • 敏感账号启用 MFA。
  • 监控泄露密码库,命中后强制重置。
  • 全站使用 HTTPS。
  • 不要记录密码、重置 Token 或会话标识。
  • 重置 Token 短期有效且只能使用一次。
  • 未知用户和错误密码使用接近的响应时间,减少账号枚举。

即使代码看起来正确,时序也很容易出错。未知邮箱因为没算哈希 5 毫秒返回,已存在邮箱配错密码因为跑了 bcrypt 250 毫秒返回,攻击者拿秒表就能枚举账号。修复方式是用户不存在时也拿一个固定的 dummy hash 验证,让两条路径付同样的成本,并返回同样的通用错误和状态码。真实原因记服务端日志,不要放进响应。

限流本身也有取舍。按账号锁定能挡住定向攻击,但攻击者也能借此把受害者锁在门外;按 IP 限制对分布式僵尸网络效果有限;全局限流保护了服务,却保护不了单个账号。应该叠加信号:按账号指数退避、IP 信誉、设备指纹,超过阈值后要求 CAPTCHA 或 MFA。返回 429 和 Retry-After,而不是笼统的 403,同时确保限流器本身不会变成未认证的拒绝服务入口。

MFA 和泄露监控处理的是哈希管不了的攻击。一个正确哈希的密码,如果在别的泄露站点复用过,仍然是有效凭证;新密码要拿泄露密码库比对(用 k-anonymity,避免把完整密码发出去),命中就强制重置。这些控制不能替代慢哈希,慢哈希也不能替代它们。

常见错误

错误风险修复
用 SHA-256 或 MD5 存密码离线快速破解使用 Argon2id、scrypt 或 bcrypt
所有用户共用一个盐彩虹表和批量破解每个密码生成唯一盐
加密密码密钥泄露导致全部暴露哈希,不加密
用 === 比较哈希时序侧信道使用库的 verify 函数
忽略 bcrypt 的 72 字节限制静默截断预哈希或明确限制长度
pepper 存在数据库里没有纵深防御存到密钥管理系统
没有迁移路径旧快速哈希长期存在登录成功时重新哈希
预哈希方案没有文档以后无法复现校验方案版本化并做跨语言测试
Argon2 内存参数太低GPU 攻击仍然便宜用 OWASP 最低值并实测
内存硬哈希没有并发上限登录突刺时 OOM排队并返回 429
用 bcrypt 哈希高熵 API Key每次请求浪费 CPU用 HMAC-SHA-256 + 常量时间比较
没有 pepper 版本字段轮换后所有用户被锁存版本并双读

测试哈希

密码哈希生成工具 可以生成 bcrypt 等哈希用于开发。bcrypt 校验工具 和 scrypt 校验工具 可以确认存储哈希是否匹配候选密码,不需要写脚本。SHA-256 生成工具 适合完整性校验,不适合密码。

只使用测试值。不要把生产密码哈希粘贴到公开工具里,也不要把浏览器工具放进生产认证链路。

本地检查可以这样做:用库允许的最低成本快速生成一个哈希,验证正确密码,验证错误密码,确认错误情况返回 false 而不是抛异常;再用一个 73 字节以上的长密码重复,观察代码是截断、拒绝还是预哈希。跨语言时,在一个运行时生成 $2b$ 哈希,在另一个运行时验证;PHP 的 password_hash 输出 $2y$,有的库需要显式兼容标志。Argon2 要确认 PHC 字符串的版本和参数经过数据库往返后没有变化;TEXT 列太短会静默截断哈希,之后每次登录都报格式错误,看起来却像密码错误。站内的 bcrypt 和 scrypt 校验工具适合用测试值做这些检查,不要拿生产记录去试。

建议

新系统使用 Argon2id,参数在本机硬件上实测。平台或合规要求倾向 bcrypt 时,选择能让校验耗时落在延迟预算内的成本因子,并显式处理 72 字节限制。旧快速哈希在登录时迁移,把 pepper 放在数据库之外,并把认证当作一个系统:哈希只是其中一层,不是全部防线。

合规要求使用 FIPS 140 验证过的密码模块时,这套建议不适用。Argon2id 和 bcrypt 都不是 FIPS 批准的密码哈希函数;验证过的部署通常使用 PBKDF2-HMAC-SHA-256,迭代次数要足够高(OWASP 给 PBKDF2-HMAC-SHA-256 的底线是 600,000 次),并在验证边界内运行,或者使用厂商提供的密码哈希服务。具体要求要找合规负责人确认,不要假设;运行时开了“FIPS 模式”并不会自动让未批准的算法合规。

上线前确认五件事:每个密码都有唯一盐;算法和成本写在记录里;校验走库提供的常量时间比较;登录时重哈希的路径有测试,旧哈希有截止日期;登录、重置、注册共用同一套限流和防枚举措施。任何一项缺失,bcrypt 和 Argon2id 选哪个都不是最重要的问题。

常见问题

SHA-256 加盐可以用来存密码吗?

加盐只能避免相同密码产生相同哈希,但 SHA-256 仍然太快。攻击者用 GPU 可以尝试海量加盐猜测。应该使用可调成本的密码哈希算法,例如 Argon2id、scrypt 或 bcrypt。

bcrypt 现在还安全吗?

在成本因子足够、并且正确处理 72 字节输入限制的前提下,bcrypt 仍然可以使用。新系统一般更推荐 Argon2id,因为它增加内存硬度,能提高 GPU 和 ASIC 攻击成本。

应该加密密码而不是哈希吗?

不应该。加密可逆,密钥泄露会暴露所有密码。密码校验只需要比较派生值,所以哈希才是正确设计。

bcrypt 成本因子应该设多少?

在认证服务的延迟预算内,选择服务器能承受的最高成本,并随硬件升级重新评估。常见起点是 12,但正确值取决于流量和硬件,应该实测校验耗时。

怎么从 SHA-256 迁移到 bcrypt 或 Argon2?

用户下次登录成功时,先用旧哈希校验,再用新算法重新哈希并替换存储值。存储记录里标记算法,校验时按算法分派。长期不登录的旧哈希可以逐步过期。

参考资料

把指南用起来

浏览全部工具

打开浏览器工具。输入内容保留在浏览器中,不会上传到业务服务器。

相关指南

全部指南