安全是大多数开发者都承认重要、却鲜有人觉得自己真正吃透的话题。一方面词汇本身就很吓人——密码学、加盐、HMAC、TOTP、非对称密钥;另一方面,做错的代价通常要等到事故发生才暴露出来。

这篇指南绕开术语堆砌,聚焦你作为 Web 开发者每天要做的具体决策:密码怎么存、数据怎么签名、用户怎么认证、令牌怎么生成和校验。每个概念都配一个可用的浏览器工具,方便你测试理解、验证实现。

读完它你不会变成密码学家,但你会建立起 Web 开发中最关键的一组安全原语的可用心智模型。


哈希:一切的基石

哈希函数把任意长度的输入压成定长输出。同样的输入永远得到同样的输出,不同的输入(几乎总是)得到不同的输出。最关键的是——你无法从哈希值反推出原始输入。

正是这种单向性让哈希在安全场景中有用。

哈希的典型用途:

  • 存储密码(永远不要存明文密码——存哈希)
  • 校验文件完整性(用下载文件的哈希对比已知正确的哈希)
  • 生成确定性标识符(哈希结构化输入得到稳定 ID)
  • 检测数据被篡改(接收数据的哈希与预期数据哈希一致即未被改动)

常见哈希算法:

算法输出长度现在还能用吗?
MD5128 位不能——已发现碰撞,安全用途禁用
SHA-1160 位不能——密码学用途已弃用
SHA-256256 位能——大多数场景的稳妥选择
SHA-512512 位能——输出更长,暴力破解更难一点
bcrypt可变能——专为密码场景设计

哈希生成器亲自算一下。把同样的输入分别灌进 MD5 和 SHA-256 看输出对比,再改一个字符看哈希值如何天翻地覆地变化(这叫雪崩效应,是刻意设计的)。

**密码哈希的关键提醒:**密码绝对不能用纯 SHA-256 或 MD5 来哈希。这些算法很快——而”快”恰恰是密码场景最不想要的,因为攻击者可以用消费级硬件每秒尝试几十亿个密码。密码场景请用 bcrypt、scrypt 或 Argon2,它们刻意设计成慢且计算昂贵。


加盐:为什么光哈希密码还不够

同样的密码用同样的算法哈希,结果一定相同。这意味着攻击者拿到你的哈希数据库后,可以用预计算表(彩虹表)反查常用密码。password123 的 SHA-256 永远是同一个值,只要数据库里出现这个值,攻击者无需任何针对该用户的计算就知道密码是什么。

是哈希前混入密码的一段随机值。每个用户独立的盐和哈希一起存储。这样两个用同样密码的用户得到的哈希也不同,彩虹表彻底失效——攻击者得为每一个可能的盐值各做一张表。

具体来看:

# 不加盐——同密码同哈希
sha256("password123") → a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3

# 加盐——同密码每用户哈希不同
sha256("password123" + "user_alice_salt_xyz") → 不同的哈希
sha256("password123" + "user_bob_salt_abc") → 又一个不同的哈希

现代密码哈希库(bcrypt、Argon2)会自动处理盐的生成,你不用手动管理。但理解它存在的原因,能帮你判断一个库有没有正确实现。


HMAC:用签名证明数据没被改过

哈希只能告诉你某段数据映射到某个特定输出,但它不能告诉你这段数据是谁创建的、是不是来自你信任的人。

**HMAC(基于哈希的消息认证码)**把哈希和一个密钥结合起来,输出一个签名同时证明两件事:数据的内容(像普通哈希一样)、以及创建者持有该密钥。

它在 Web API 中应用极广:

  • Webhook 签名(Stripe、GitHub 等用 HMAC 签 webhook payload,方便你验证来源)
  • API 请求签名(AWS Signature Version 4 用 HMAC 签 API 请求)
  • 防止 Cookie 篡改(用 HMAC 签 cookie 内容,每次请求验证)

HMAC 生成器试试看。给一段消息和密钥生成 HMAC,然后改消息中的一个字符再生成——HMAC 完全变了。再试着不知道密钥的情况下重现原 HMAC——你做不到。

Webhook 验证的实战代码:

import hmac
import hashlib

def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()
    # 用 hmac.compare_digest 防止时序攻击
    return hmac.compare_digest(expected, signature)

注意用的是 hmac.compare_digest 而不是 ==。时序攻击是真实威胁:朴素的字符串比较在发现第一个不匹配字符时就提前返回,泄露出已匹配的字符数。compare_digest 不论差异在哪都以恒定时间运行。


JWT:紧凑的声明式令牌

JSON Web Token 是现代 Web 应用承载身份声明的主流格式。一个 JWT 由三段用点分隔的 base64url 编码 JSON 组成:header、payload、signature。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

header 包含算法,payload 包含声明,signature(用密钥或私钥计算)防止篡改。

JWT 解码器查看任意 JWT 的内部结构。解码器无需密钥就能展示 header 和 payload。

JWT 的关键安全规则:

**1. 永远校验签名。**解码 ≠ 校验。任何库都能在不知道密钥的情况下解码 JWT——未经校验的声明对真实性一无所知。后端必须在每次请求时用预期密钥或公钥验证签名。

**2. 检查 exp(过期时间)声明。**过期的 JWT 即使签名有效也必须拒绝。“用户登出后为什么还是认证状态?“这类 bug 多半是验证路径没真正执行 exp 检查。

**3. 警惕 alg: none。**部分 JWT 库历史上接受 header 里 "alg": "none" 的令牌,直接绕过签名校验。确认你用的库显式拒绝 none

**4. 高敏感场景别把 JWT 放 localStorage。**localStorage 对 JavaScript 可读,意味着一个 XSS 漏洞就能把所有令牌偷走。高价值认证令牌请放 HttpOnly cookie,JavaScript 读不到。

**5. payload 保持精简。**JWT 的 payload 只是 base64 编码,没有加密。任何拿到令牌的人都能读出声明。别把密钥、密码、敏感 PII 放进去。


TOTP:基于时间的一次性密码(认证器 App 的工作原理)

双因素认证已是任何含用户账户应用的基本安全要求。理解它的内部机制有助于正确实现并在出错时定位问题。

**TOTP(基于时间的一次性密码,RFC 6238)**的工作流程:

  1. 设置阶段,服务器生成一个密钥(通常是 20 字节随机值,用 base32 编码)
  2. 密钥通过二维码分享给用户的认证器 App
  3. 此时服务器和 App 共享同一个密钥
  4. 生成验证码时,双方各自计算 HMAC-SHA1(secret, floor(current_time / 30))
  5. 结果截断为 6 位数字
  6. 因为双方用同样的密钥和同样的当前 30 秒窗口,所以得到同样的 6 位码

二维码本质上就是密钥和元数据的 URI 编码:otpauth://totp/Issuer:[email protected]?secret=BASE32SECRET&issuer=Issuer&algorithm=SHA1&digits=6&period=30

TOTP 生成器测试 TOTP 生成。生成一个密钥,然后看验证码每 30 秒变化一次——这正是 Google Authenticator 或 Authy 内部发生的事。

TOTP 实现常见错误:

  • 没留客户端和服务器之间的小幅时钟漂移窗口(±1 个周期)
  • 没有阻止同一个 30 秒窗口内重复使用验证码
  • TOTP 密钥明文存储(应当加密存储或放进密钥管理服务)
  • 用过长的周期(300 秒而非 30 秒)——更方便但攻击窗口大得多

非对称密码学:RSA 密钥用于签名和加密

上面所有原语都用对称密码——同一个密钥既用于创建也用于验证。非对称密码学使用一对数学关联的密钥:公钥和私钥。一个加密的,只有另一个能解密;一个签名的,另一个能验证。

RSA 是最广泛使用的非对称算法,常见用途:

  • TLS 证书(服务器出示的证书包含公钥;浏览器用它建立加密连接,无需知道你的私钥)
  • JWT 配 RS256(服务器用私钥签 JWT;客户端用公钥验证,从而无需共享签名密钥)
  • SSH 密钥~/.ssh/id_rsa.pub 是公钥;私钥留在你机器上;服务器存公钥即可认证你,私钥从不离开本地)
  • 代码签名(软件发布方用私钥签发布版本;用户用公钥验证)

RSA 密钥对生成器生成一对 RSA 密钥。注意公钥从私钥派生而来——它们不是独立的,是同一个数学对象的两面。

私钥处理铁律:

  • 私钥永远不要提交到版本控制。临时也不行,私有仓库也不行。
  • 私钥永远不要通过明文渠道传输(邮件、Slack 等)
  • 私钥存进密钥管理服务(AWS Secrets Manager、HashiCorp Vault、Google Secret Manager)
  • 任何接触过私钥的人离职后立刻轮换密钥

公钥密码学的强大之处在于这种非对称性,而它的不留情面也正源于此。公钥可以随便分享——挂在网站上、放在 API 响应里、硬编码进客户端 App 都可以。但私钥一旦泄露,所有依赖它的安全保证全部作废。


密码生成:什么才算”强”密码

大多数安全系统最薄弱的环节是用户自选密码。让用户自选密码时,相当大一部分人会选 password123、宠物名字或出生年份。给他们生成密码或强制复杂度要求时,结果通常更好但也未必是你想要的。

什么让密码变强?

**长度是首要因素。**8 位密码从 95 字符的字母表里取,约有 53 比特熵;同样字母表的 16 位密码约有 105 比特。攻击者的成本随每比特熵翻一倍,长度是最有效的杠杆。

**随机性比复杂度更重要。**12 位随机小写字母密码比可预测的”复杂”密码 P@ssw0rd!1 更强。复杂度要求往往让密码更难记却没让它更难猜,因为人类创造的复杂模式高度可预测。

需要生成凭证时(临时密码、API 密钥、服务账号凭证、恢复码),用密码生成器。基于浏览器的密码学随机生成可靠且不会把生成值暴露给任何第三方服务器。

对于用户自己创建的密码,用强密码哈希函数(bcrypt 成本因子 ≥ 12,或 Argon2id),再用泄露密码数据库做核对(Have I Been Pwned 的 API 支持范围查询,能保护隐私)。


收尾:安全认证检查清单

任何带用户认证的 Web 应用,请逐项确认:

  • 密码以 bcrypt/Argon2id 哈希存储,每用户独立盐(绝不明文或纯 SHA-256)
  • JWT 每次请求都校验(签名 + 过期 + issuer + audience)
  • Webhook payload 处理前用 HMAC 验证
  • 提供 TOTP 或其他 2FA(管理员账号强制开启)
  • RSA 或 ECDSA 私钥存进密钥管理服务,绝不在源码中
  • HTTPS 全站强制(无混合内容,HSTS 头已设置)
  • 敏感 cookie 设置 HttpOnly 和 Secure 标志
  • 密码重置令牌一次性且短期有效(≤15 分钟)
  • 认证端点有限流

安全是设计和实现每一步都要做的决策集合,不是末尾才加上的功能。哈希、HMAC、JWT、TOTP、RSA 这些原语都成熟、有文档、在每种主流语言的标准库里都能找到。

Web 应用安全漏洞的主要来源不是密码学知识不足,而是正确原语的错误使用:该用慢哈希的地方用了快哈希,忘了校验 JWT 签名,把密钥放进了访问权限过宽的环境。

把原语理解到能正确应用,是务实的目标。上面的工具帮你建立和验证这种理解。用起来——遇到看着不对的东西,先格式化、解码、读出真实值,再下任何结论。

安全永远在细节里。