# 密码哈希：为什么不能用 SHA-256，bcrypt 又该怎么配

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

**快速答案:** 密码必须使用专门的慢哈希算法，例如 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。加盐可以防预计算彩虹表，但不能阻止针对性的暴力猜测。

```text
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，工作量翻倍。

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

关键特性：

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

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

```js
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。
- **迭代次数**：在内存上跑几遍。
- **并行度**：可以并行多少条通道。

```js
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 时需要在登录时重新哈希。

```text
存储值 = 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 轮换会让所有用户都收到“密码错误”，支持团队还无法把它和真实泄露区分开。动线上密钥之前，先用接近生产的数据在预发环境演练一遍。

## 校验和迁移

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

```ts
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 版本字段 | 轮换后所有用户被锁 | 存版本并双读 |

## 测试哈希

[密码哈希生成工具](/tools/password-hash) 可以生成 bcrypt 等哈希用于开发。[bcrypt 校验工具](/tools/bcrypt-verifier) 和 [scrypt 校验工具](/tools/scrypt-verifier) 可以确认存储哈希是否匹配候选密码，不需要写脚本。[SHA-256 生成工具](/tools/sha-hash) 适合完整性校验，不适合密码。

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

本地检查可以这样做：用库允许的最低成本快速生成一个哈希，验证正确密码，验证错误密码，确认错误情况返回 `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？

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

## 参考资料

- [OWASP - 密码存储备忘单](https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html)
- [NIST SP 800-63B - 数字身份指南](https://pages.nist.gov/800-63-4/sp800-63b.html)
- [RFC 9106 - Argon2 内存硬函数](https://www.rfc-editor.org/rfc/rfc9106)
- [bcrypt - 可适应未来的密码方案](https://www.usenix.org/legacy/events/usenix99/provos/provos_html/node1.html)

---

- Canonical: https://www.jsonmind.com/zh-CN/guides/password-hashing-bcrypt-vs-sha256
- Locale: zh-CN
- Published: 2026-11-09
- Updated: 2026-11-09
- Tools: password-hash, bcrypt-verifier, scrypt-verifier, sha-hash