记得有一年双十一一大促前的一个深夜,我盯着监控大屏上不断攀升的响应时间曲线,心里直发毛。我们那个拥有 200 万日活(DAU)的社区应用,在流量高峰时接口平均耗时从平时的 120ms 飙升到了 800ms 以上。当时我就在想,明明数据库和缓存都加了,为什么用户登录后刷个首页还要这么久?
排查下来,罪魁祸首竟然是 Session。
当时的架构很传统:用户登录成功后,我们在 Nginx 后面挂了 4 台 Node.js 应用服务器,用户的 Session 数据存在了其中一台 Redis 实例里。看起来没毛病,对吧?但问题出在扩容和 Session 共享上。
当流量翻倍,我们需要快速扩容服务器。新启动的容器因为没有对应的 Session 数据,不得不去访问那台共享的 Redis。这就导致了一个现象:每次请求都要有一次网络 I/O 去 Redis 查询 Session。在 QPS 达到 5000 的时候,Redis 的连接数和查询延迟成了瓶颈。更要命的是,如果那台 Redis 挂了,所有用户都得重新登录,这种单点故障风险是我们无法承受的。
那时候我就在考虑,能不能让请求到了服务器这一层,不需要再去查库,直接自己就能知道这个用户是谁?这就是我转向 JWT 的直接原因。
JWT(JSON Web Token)解决了这个痛点。它把用户信息(比如用户 ID、角色)放在 Token 里,然后用密钥签名。服务端收到 Token 后,只需要用密钥验证签名是否合法,就能确认用户身份,全程 零 I/O 操作。
为了验证我的想法,我做了一个简单的压力测试。环境是 4 核 8G 的云主机,Node.js 版本 18.x,测试接口就是一个简单的获取用户信息的 API。
在 Session + Redis 的方案下,模拟 1000 个并发用户,持续 30 秒:
换成 JWT 方案后(使用 jsonwebtoken 9.0.2,算法 HS256):
数据不会撒谎。QPS 提升了将近 3 倍,延迟直接降到了原来的四分之一。这是因为 JWT 的验证过程完全是 CPU 计算,不需要等待网络。对于那种需要快速响应的 API 接口,这种提升是决定性的。
当然,我并不是说 Session 不好。如果你在做单体应用,或者用户量不大,Session 配合内存存储其实是最简单的。但在我们这种需要横向扩展、微服务化、甚至打算把部分服务迁移到边缘计算(比如 Cloudflare Workers)的场景下,JWT 的 无状态性 简直是救命稻草。
我后来把核心鉴权逻辑重构了,把用户 ID 和权限塞进 JWT 的 Payload 里。这样,甚至在下单、查订单这些业务逻辑里,我都不用去查用户表了,直接从 Token 里拿数据。这省去了大量的数据库查询,尤其是在大促期间,数据库每少一条查询,系统就稳一分。
不过,这也带来了新的问题。有一次线上接口突然变慢,排查下来发现是因为某个开发把用户的头像 URL、昵称、甚至一些偏好设置都塞进了 JWT 的 Payload 里。导致 Token 体积巨大,每次请求 HTTP 头部都膨胀到了好几 KB,在网络传输上反而成了负担。这也是我后面要提到的,JWT 虽好,但 Payload 一定要精简,只放必要的非敏感信息。
很多同学看 JWT 觉得它很神秘,其实你把它拆开看,就是三个 Base64 字符串拼在一起,中间用点隔开。RFC 7519 标准定义了这三部分:Header(头部)、Payload(载荷)、Signature(签名)。
我习惯把它比喻成一封“防篡改的信”。
假设你要给朋友寄一张银行卡和一张纸条。你不能直接把卡和纸条扔进信封,因为路上被人换了你不知道。
在 Node.js 里,如果你用 jsonwebtoken 库,生成的 Token 大概长这样:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsInJvbGUiOiJhZG1pbiIsImlhdCI6MTYwMDAwMDAwMH0.1H_0YpVZ9nH_...
我以前在做一个内部审批系统的时候,遇到过一个让我后背发凉的安全问题。当时我们用的库版本比较老,大概是 8.x 的早期版本。有一个安全扫描报告提示我们可能遭受 算法混淆攻击 (Algorithm Confusion)。
攻击原理是这样的:JWT 的 Header 里有个 alg 字段。如果服务端没有强制指定算法,攻击者可以把 alg 改成 none。有些旧的库在解析 alg: none 时,会跳过签名验证,直接放行。更狠的是,如果攻击者拿到了服务端的公钥(比如有些系统把公钥放在了 /.well-known/jwks.json 里),他可以把 alg 改成 HS256,然后用这个公钥当作密钥去签名。因为服务端可能配置成了“如果是 HS256 就用密钥验证”,而它恰好有这个公钥,于是验证就通过了。
这听起来很绕,但在真实世界里确实发生过。我当时的解决方案非常直接,也是现在 jsonwebtoken 9.x 版本推荐的做法:永远不要信任 Token 里的 alg 字段,服务端必须硬编码指定算法。
我们来看一下正确的防御代码。在 jsonwebtoken 9.0.2 中,验证 Token 时必须显式指定 algorithms 数组。
除了算法混淆,还有个细节是关于 Secret 的管理。我见过有团队把密钥直接写在代码里,甚至提交到了 GitHub。这简直是把钥匙挂在门上。JWT 的安全完全依赖于这个密钥。如果泄露了,攻击者可以伪造任何用户的 Token。在我负责的项目里,我们通常会把密钥放在 KMS(密钥管理服务)里,或者至少放在环境变量中,并且定期轮换。
另外,关于签名机制,我建议大家在理解上稍微深入一点。签名并不是把 Header 和 Payload 加密了,而是对它们进行了哈希运算。这意味着,任何人都可以用 Base64 解码看到 Payload 里的内容。所以,千万不要在 JWT 里放密码、身份证号这种敏感信息。它的安全性在于“不可篡改”,而不在于“不可见”。
单纯引入 JWT 只是第一步,真正让我头疼的是 Token 的过期和续期问题。如果你把 Token 有效期设长(比如 7 天),一旦 Token 被盗,黑客就能爽 7 天;如果设短(比如 15 分钟),用户就得每 15 分钟重新登录一次,体验极差。
为了解决这个矛盾,我在我们那个社区项目里落地了 双令牌方案 (Access Token + Refresh Token)。这套方案的核心是:
具体的实现逻辑是这样的:用户登录时,我同时生成两个 Token。Access Token 我放在 HTTP 响应的 JSON Body 里,让前端存到内存或者 LocalStorage 里;Refresh Token 我通过 HttpOnly 的 Cookie 种给浏览器。
为什么这么设计?因为 HttpOnly Cookie 前端 JS 读不到,能有效防止 XSS 攻击窃取 Refresh Token。而 Access Token 虽然可能被读到,但它 15 分钟就过期了,风险可控。
以下是基于 jsonwebtoken 9.0.2 的完整实现代码。这是我精简后的生产级代码片段:
在实际部署这套方案时,我遇到过一个坑。有一次线上用户反馈,登录后频繁掉线。查了半天,发现是因为服务器时间和用户本地时间不一致,导致我生成的 15 分钟过期 Token,在用户那边可能几分钟就失效了。后来我统一使用 UTC 时间戳 来做逻辑判断,并且在前端检测到 401 错误时,自动去调用 /api/refresh_token 接口,拿到新的 Access Token 后重试原请求。这样用户完全感知不到 Token 的刷新过程。
另外,关于 Token 的撤销问题。JWT 是无状态的,一旦发出去,除非它过期,否则服务端没法让它立刻失效。这在用户修改密码或者账号被盗时是个大问题。我的做法是配合 Redis 做一个 黑名单机制。当用户修改密码时,我把旧的 Access Token 的 jti(JWT ID)或者签名部分存到 Redis 里,设置过期时间为 Token 的剩余有效期。每次验证 Token 时,先去 Redis 查一下这个 Token 是不是在黑名单里。虽然这稍微破坏了一点无状态性,但在安全面前,这点 I/O 开销是值得的。
去年双十一大促前,我们那个电商后台系统突然接到安全组反馈,说有用户投诉账号在异地登录,而且那个账号明明已经修改了密码,但旧的登录状态居然还能用。我当时第一反应是 Session 不是已经清了吗?结果一查,我们为了扛高并发,早在半年前就把用户会话从 Redis Session 迁移到了 JWT 无状态方案,用的还是 jsonwebtoken@9.0.2 这个版本。
这就尴尬了。JWT 一旦签发,在过期前服务端是没法主动干掉它的,除非你把密钥换了,但换密钥意味着全平台几万个用户都得重新登录,这在大促前夕简直是自杀行为。当时那个出问题的用户,Token 有效期设的是 7 天,他改完密码后,那个旧 Token 还能继续下单、查订单,直到 7 天后才自然失效。
那次事故之后,我意识到单纯的无状态 JWT 在需要强制下线、修改密码、或者封禁账号的场景下是有硬伤的。我们当时 QPS 峰值在 8000 左右,如果为了撤销 Token 去给每个请求都查一次数据库,数据库肯定扛不住。最后我定的方案是引入 Redis 黑名单,作为 JWT 无状态的一个补充。
具体的逻辑是这样的:当用户修改密码或者主动退出登录时,我们把这个 JWT 的 jti(JWT ID,唯一标识)或者整个 Token 的签名哈希扔进 Redis,设置过期时间跟 Token 本身的剩余有效期一致。这样 Redis 里只存那些“作废”的 Token,而不是存所有在线的 Token,内存占用非常低。
我们当时算了一笔账,假设日活 10 万,平均每人每天注销或改密 0.1 次,那 Redis 里也就多 1 万个 Key,每个 Key 存 1 小时或 7 天,开销几乎可以忽略。
下面是我们在 Express 中间件里加的校验逻辑,核心就是多查一次 Redis:
这里有个细节,jwt.decode 是不验证签名的,速度很快,只是用来拿 Payload 里的 exp 和 jti。我们给每个 Token 生成的时候都会加一个唯一的 jti,这样黑名单才能精准打击。如果不加 jti,用 UserId 做 Key 的话,会导致这个用户的所有 Token 都被踢下线,体验不好。
聊到存储,这绝对是前端和后端撕得最多的一个点。以前我图省事,直接让前端把 Token 存到 localStorage 里,然后请求的时候塞进 Authorization Header。直到有一次我们内部的安全扫描报告指出来,这种方案在遭遇 XSS(跨站脚本攻击)时,Token 会被脚本直接偷走。因为 localStorage 可以通过 document.localStorage 直接读取,只要页面上有个没过滤好的富文本或者注入点,黑客就能把用户的 Token 发到自己的服务器,然后伪造用户身份。
后来我把方案改成了 HttpOnly Cookie。这个改动的核心逻辑是:Cookie 虽然会自动随请求发送,但是前端 JS 是读不到它的。这样就算页面被注入了恶意脚本,它也拿不到 Token,只能干瞪眼。
但是,用 Cookie 就引入了另一个麻烦:CSRF(跨站请求伪造)。因为浏览器发请求时会自动带上 Cookie,如果有个恶意网站诱导用户点击,那个网站向你的 API 发请求,浏览器也会带上那个合法的 JWT Cookie。
为了防 CSRF,我现在的做法是双管齐下。首先,Cookie 设置 SameSite=Strict 或者 Lax。Strict 最安全,但会导致用户从外部链接跳转过来时(比如邮件里的链接)带不上 Cookie,体验稍差;Lax 是个折中方案,允许顶级导航(比如链接跳转)带上 Cookie,但不允许跨站的 POST 请求带上。其次,对于涉及资金变动或者敏感操作的接口,我还会加一个自定义的 Header 校验,比如 X-Requested-With: XMLHttpRequest,因为跨站请求是没法伪造这种非简单 Header 的(除非配合 CORS 漏洞,但那是另一个话题了)。
下面是我们 Node.js 端设置 Cookie 和校验的代码:
这里有个坑我得提一下,用了 HttpOnly 之后,前端就没法知道 Token 什么时候过期了。以前存在 localStorage 里,前端还能读 Payload 看 exp 字段,现在读不到了。我的解决办法是后端在返回用户信息的接口里,顺便返回 Token 的剩余有效期,或者前端通过定时调用一个轻量的 /api/refresh 接口来续期。如果后端返回 401,前端就直接跳转到登录页。
最近我在研究 Cloudflare Workers 重构我们的一些边缘服务,这就牵扯到 JWT 在边缘计算环境下的适配问题。传统的 jsonwebtoken 库虽然好用,但它依赖 Node.js 的一些底层加密模块,在 Cloudflare Workers 这种基于 V8 Isolate 的轻量化环境里跑不起来,或者体积太大。而且,随着 PQC(后量子密码学)的预热,我也开始关注 JWT 算法未来的变化。
现在的趋势是,大家开始倾向于用更轻量的 JWT 库,比如 jose(这个库在 2024 年非常火,支持 Web Crypto API,零依赖,非常适合边缘环境)。另外,关于安全的一个新动向是 DPoP (Demonstrating Proof-of-Possession)。
以前我们验证 JWT,只要签名对、没过期,我们就认为请求是合法的。但这有个漏洞:如果黑客通过抓包或者 XSS 拿到了 Token,他就可以拿着这个 Token 在任何设备上用,服务端分不清是合法用户还是黑客。DPoP 就是为了解决这个问题,它把 JWT 和客户端的某个密钥(比如公钥或者一个随机生成的 Secret)绑定在一起。每次请求,客户端不仅要发 Token,还要发一个签名,证明“我确实持有这个 Token 对应的私钥”,而且这个签名是单次有效的,防止重放攻击。
虽然 DPoP 目前更多用在 OAuth 2.0 的 Access Token 场景,但我觉得这种“持有性证明”的思路以后会下沉到普通的 API 鉴权里。
回到边缘计算,我在 Cloudflare Workers 上写了一个简单的 JWT 验证,用的是 jose 库,因为它支持标准的 Web Crypto API,不用折腾 Node 的原生模块。
在边缘环境里,我特别注意了 JWT 的体积。以前在服务器上,我可能随手就在 Payload 里塞一堆用户信息,比如 { userId: 1, name: 'xxx', role: 'admin', email: '...', avatar: '...' }。但在边缘计算场景下,每个请求都要经过全球节点,如果 JWT 太大,会增加请求头的大小,影响传输效率。我现在的原则是 Payload 里只放 userId 和 exp,其他的去查边缘 KV 或者回源。
另外,关于那个 jsonwebtoken@9.0.2 版本,它修复了之前的一些安全漏洞,但如果你要上边缘计算,我还是推荐试试 jose。它更新更活跃,而且原生支持很多新特性,比如对 EdDSA (Ed25519) 算法的支持,这在未来应对量子计算威胁时,可能会比传统的 RSA 更灵活一些。我现在的策略是,核心业务还在 Node.js 服务器上跑,用 jsonwebtoken;新的边缘函数或者 Serverless 函数,直接上 jose。
去年我接手了一个社区项目的重构,DAU 大概在 50 万左右。当时为了追求所谓的“无状态架构”,我硬是把所有用户会话都改成了 JWT,而且为了省事,直接把用户权限字段塞进了 Payload 里。
上线第一周风平浪静,结果第二周运营反馈有个恶意用户一直在刷接口。我打算临时封禁这个账号,改了数据库里的状态,结果那家伙还能继续访问。当时我就懵了,查了半天才反应过来:JWT 一旦签发,在过期前服务端根本管不了它。
那个晚上我紧急加了个 Redis 黑名单逻辑,在中间件里强行查库比对,虽然解决了燃眉之急,但原本引以为傲的“无状态”瞬间打了折扣,QPS 也掉了一大截。
折腾完这一圈,我对 JWT 的看法变了:
* 适合的场景:如果你的服务是分布式的,或者需要给第三方开放 API,JWT 确实省去了 Session 同步的麻烦,跨端(比如 App 和 Web 共用接口)也很方便。
* 不适合的场景:如果你只是个单体应用,或者用户量不大,真的没必要硬上。Session + Redis 的体验其实更丝滑,至少你能随时踢人下线。
* 我的取舍:我现在基本只把 JWT 当 Access Token 用,有效期设得极短(比如 15 分钟),配合 Refresh Token 使用。至于敏感权限,我绝对不会再往 Token 里塞了。
别把 JWT 当成 Session 的替代品,它俩解决的是不同维度的问题。
如果你正在学这个,别光看文档里怎么生成 Token,多去研究一下签名验证的源码逻辑和密钥管理。很多攻击其实不是因为 JWT 不行,而是开发者把密钥硬编码在了代码里。安全这东西,细节决定生死。