我用 JWT 做过几个后台系统的登录态,踩过的坑基本都能在 OWASP 的清单里找到对应条目。写下来当自查表用。

算法与密钥

最出名的问题是 alg=none。早期一些库会信任请求头里的 alg,攻击者把它改成 none,签名校验直接被跳过。现在的库大多默认拒绝,但如果你的代码手动解析 header 再选算法,就等于把这个洞重新打开。

正确的做法是校验时把算法写死,不要读 token 里的值。

import jwt from 'jsonwebtoken'

const payload = jwt.verify(token, process.env.JWT_SECRET, {
  algorithms: ['HS256'],
  issuer: 'api.example.internal',
  audience: 'web-client',
  clockTolerance: 5,
})

密钥强度经常被忽略。HS256 的密钥至少是 32 字节的随机值,不要用口令或者项目名拼一拼。RS256 的话,公钥分发要跟着 JWKS 走,别把私钥塞进前端构建产物。

还有一个容易漏的点是算法混用。同时支持 HS256 和 RS256 的库,如果校验时不限定算法族,攻击者可以拿公钥当 HMAC 的密钥来签名,服务端用公钥验签就会通过。这类攻击不需要破解任何密钥,只需要拿到一份本来就公开的公钥。

过期、刷新与注销

  • access token 短,5 到 15 分钟,泄露后的窗口小
  • refresh token 长,但必须能撤销,服务端要有记录
  • 刷新时轮换 refresh token,旧的立即失效
  • 注销时把 refresh token 加入黑名单,access token 等它自然过期

JWT 是无状态的,这句话经常被误解成不能注销。实际上可以注销,只是需要额外的状态,代价是每次请求多一次查询或者缓存判断。如果业务不能接受这个代价,那就该老实承认:退出登录只是前端删了 token,服务端并不知情。

刷新接口本身也要鉴权。我见过把 refresh token 放在查询参数里的实现,它会被完整写进访问日志和浏览器历史,等于把长期凭据摊在明面上。

存储位置

localStorage 简单,但任何 XSS 都能读走。HttpOnly Cookie 挡住了 JS,但要处理 CSRF,SameSite=Lax 能覆盖大部分场景,跨站表单提交仍要加 token。

我现在的默认选择是:同站应用用 HttpOnly 加 SameSite=Lax 的 Cookie,跨域 API 用内存里的 access token 配 HttpOnly 的 refresh cookie。

受众与签发者

这两个字段不是可选项。多服务共用一套密钥时,A 服务的 token 不该能访问 B 服务。iss 和 aud 校验能把这类越权挡住一大半。同理,sub 里放用户 id 而不是邮箱,避免用户改邮箱后旧 token 的语义漂移。

还有一个实践细节:token 里不要放权限列表。权限会变,而 token 在过期前一直有效,改权限就得等它失效。放角色名或者一个版本号,真正的权限在服务端查,撤销也更快。

一条经验:把校验逻辑收在一个函数里,所有入口都走它。分散在各处的 verify 调用,早晚会有一个忘了加 algorithms。