上篇讲了哈希。这一篇聚焦一个每个开发者都绕不开、又最容易写错的地方:用户的密码该怎么存? 答案不是”加密”,而是”加盐哈希”。但绝大多数新手都踩过坑。
读完这篇,你能说清为什么 md5(password) 是最危险的写法,以及正确方案长什么样。
错误示范:直接哈希存密码
很多老代码这么干:
数据库中存: md5("123456") = e10adc3949ba59abbe56e057f20f883e
看起来”加密了”,其实漏洞百出:
- ** md5 已破且快**:现代 GPU 一秒能算几十亿次 md5,暴力穷举 + 字典攻击分分钟破解。
- 相同密码哈希相同:所有人密码是
123456,哈希都一模一样。攻击者拿到库,一眼看出哪些人密码相同,还能用彩虹表(预先算好常见密码的哈希对照表)反查。 - 没有盐:彩虹表直接命中,毫无阻力。
所以”md5(密码)“基本等于明文存储——一旦数据库泄露,密码全裸奔。
什么是”盐(Salt)”
盐是一段随机字符串,每个用户不同,存密码时把它和口令拼在一起再哈希:
salt = 随机8字节(每个用户不同,明文存库)
存储值 = hash(salt + password)
关键性质:
- 盐是每个用户唯一的随机值,哪怕两个人口令都是
123456,因为盐不同,存下来的哈希也完全不同。彩虹表(针对”无盐常见密码”预计算的)彻底失效——攻击者得对每个用户单独算,成本暴涨。 - 盐不需要保密,可以明文存在数据库里(它靠”随机且唯一”起作用,不靠保密)。
但光加盐还不够——如果哈希算法本身太快(如 md5/sha256),攻击者仍能对每个用户暴力穷举。于是需要慢哈希。
慢哈希:bcrypt / scrypt / Argon2
普通哈希(SHA 系列)设计目标就是”快”,这对校验文件完整性是优点,对存密码却是缺点——攻击者算得也快。解决方案是用故意设计得慢的哈希算法:
- bcrypt:内置盐、可调节”成本因子”(迭代次数),成本越高越慢。至今可靠。
- scrypt:额外消耗内存,对抗用专用硬件(ASIC/GPU)的暴破。
- Argon2:2015 年密码哈希竞赛冠军,兼顾 CPU 和内存消耗,当前首选推荐。
它们的工作方式(以 bcrypt 为例):
存储格式: $2b$12$salt字符串$哈希结果
验证时: 取出 salt 和成本,对输入口令重算,比对哈希是否一致
你不需要自己实现,直接用成熟库(如 Node 的 bcrypt、argon2 库):
const bcrypt = require('bcrypt');
const hash = await bcrypt.hash('用户密码', 12); // 12 = 成本因子
const ok = await bcrypt.compare('用户输入', hash); // 验证
bcrypt.hash 自动生成盐并拼进结果,compare 自动取盐重算比对。别自己手写盐逻辑,用库最稳。
完整正确的口令存储方案
- 用 Argon2id 或 bcrypt(别用 md5/sha 直接存)。
- 盐由算法自动生成,每用户唯一,无需你手动管理。
- 存的是哈希,永远不存明文、不存可逆加密的密码。
- 登录验证:对用户输入的口令用同一算法重算并比对,一致则通过。
- 传输层用 HTTPS,避免口令在网络上被窃听(哈希只管存储安全,不管传输)。
- 加防暴破措施:登录失败次数限制(如 5 次锁定)、验证码、异地登录提醒。
密码学之外的加固:多因素认证
即使口令哈希做得完美,用户用 123456 这种弱口令,暴破仍可能成功。所以重要系统要上 MFA(多因素认证):口令 + 手机验证码/动态令牌/指纹。即便密码泄露,没有第二因子也登不进。
常见坑位提醒
- 用 md5/sha1 直接存密码:已破且快,等于明文。必须慢哈希+盐。
- 盐写成固定值或全局一个盐:等于没盐,彩虹表仍能批量破解。盐必须每用户随机唯一。
- 自己实现哈希算法/盐逻辑:容易出 bug(如盐可预测、比较用
==有计时攻击)。用成熟库。 - 用
==比较哈希:普通字符串比较会”短路”,可能被计时攻击测出前缀。用库提供的恒定时间比较(bcrypt.compare 已处理)。 - 忘记传输安全:存储哈希再好,口令在 HTTP 明文传输被截获也白搭。必须 HTTPS。
- 明文找回密码:“找回密码”发你原密码的网站,说明它存了明文——立刻拉黑。正确做法是”重置”发临时链接。
实战:用 Argon2id 与 bcrypt 写对代码
理论说完,给两段可直接用的代码。Node 用 argon2 库(首选):
const argon2 = require('argon2');
// 注册:生成哈希(自动加盐,参数已合理默认)
const hash = await argon2.hash(password, { type: argon2.argon2id });
// 登录:恒定时间比对,返回布尔
const ok = await argon2.verify(hash, inputPassword);
Python 用 argon2-cffi 或 bcrypt:
from argon2 import PasswordHasher
ph = PasswordHasher() # 默认 argon2id
hash = ph.hash("用户密码")
ok = ph.verify(hash, input_pwd) # 验证,错则抛异常
要点:永远用库的高层 API,它已经帮你处理好盐、成本因子、恒定时间比较。自己手搓哈希逻辑(尤其比较时用 ==、盐可预测)是最常见的致命错误。
成本因子怎么定:在安全和体验间取舍
慢哈希的”慢”是可通过参数调的。以 bcrypt 成本因子 cost=12 为例,单次哈希约几十毫秒;Argon2 还能分别调时间成本、内存成本、并行度。调参原则:
- 选一个”单次哈希 50~200 毫秒”的成本——既能拖垮暴破,又不至于让用户登录卡顿。
- 服务器越强,越可以把成本调高(攻击者也要付出同等代价)。
- 成本参数写在存储的哈希串里(如
$2b$12$...),验证时自动读取,升级成本无需迁移旧数据,新用户用新成本、老用户登录时顺带用新成本重算即可。
密码重置令牌:另一种”凭证”
“忘记密码”发重置链接,链接里的 token 也要按口令级标准对待:
- token 用密码学安全随机数生成(如 32 字节
randomBytes),而非自增 ID 或时间戳。 - token 的哈希(而非明文)存库,和口令同理防拖库后被滥用;验证时比对哈希。
- 设短有效期(如 30 分钟)和单次使用(用后即作废),降低泄露风险。
- 正确做法是”重置”发临时链接,绝不在邮件里发原密码——能发你原密码的网站,说明它存了明文。
防暴破:口令之外的纵深防御
即便哈希再强,弱口令(如 123456)经字典攻击仍可能破。所以存储安全只是第一层,还要:
- 登录限流:同一账号/同 IP 连续失败 N 次(如 5 次)锁定一段时间或要求验证码,直接抬高暴破成本。
- 弱口令检查:注册/改密时比对常见弱口令字典(甚至接 HaveIBeenPwned 泄露库),拒绝
123456、生日等。 - 多因素认证(MFA):口令 + 短信/OTP/指纹,即使口令泄露也登不进。
- 异常检测:异地登录、新设备登录发提醒,给用户补救窗口。
安全是纵深防御:单层再强也有漏,多层叠加才稳。口令哈希是地基,上面还要盖限流、MFA、监控的楼。
小测验:看看你掌握了没
- 问题一:为什么 md5(密码) 危险?答案:md5 快可暴破、同密码同哈希、无盐被彩虹表反查,等同明文。
- 问题二:盐为什么能防彩虹表?答案:每用户随机唯一盐,使相同口令哈希不同,预计算表失效,需逐用户重算。
- 问题三:为什么普通 SHA 不适合存密码?答案:SHA 设计追求快,暴破成本低;应用 bcrypt/Argon2 等慢哈希。
这一篇你该记住的
- 直接 md5/sha 存密码 = 裸奔:快可暴破、同密同值、无盐被彩虹表。
- 盐:每用户随机唯一、可明文存,使相同口令哈希不同,废掉彩虹表。
- 用慢哈希:Argon2id(首选)/ bcrypt / scrypt,故意慢以抗暴破。
- 用成熟库(argon2/bcrypt),别自写盐与比较逻辑;比较须恒定时间防计时攻击。
- 配套:HTTPS 传输、登录限流防暴破、重要系统上 MFA;“找回密码”应重置而非发原密。
口令安全解决了”身份凭证怎么存”。但身份本身怎么被信任?这就要靠公钥基础设施(PKI)与 TLS 证书——下一篇我们讲它如何为网络通信背书。