当 JWT 认证变为有状态时:在 Node 中使用服务器端会话的必要性
黑名单与刷新存储机制本身就让 JWT 认证具有状态性。Node.js 中基于 Postgres 的 Express 会话机制更为简单,且在适合使用 JWT 的场景下表现良好。
来源: Forget JSON Web Tokens: Server-Side Sessions Cut Our Auth Bugs By 70%
无状态JWT认证在白板上看起来很完美,但许多团队发现,退出登录、强制下线以及密码重置等功能实际上会悄悄引入状态管理。一旦涉及到撤销黑名单、刷新令牌存储以及自定义中间件,认证层就会成为系统中最脆弱的部分。本文将分析这种故障模式,展示如何在Express框架中通过Postgres支持的会话机制来替代它,并解释在哪些特定情况下JWT仍然是更优的选择。
一种常见的故障模式
想象这样一个团队:他们原本为自身的JWT配置感到自豪,直到支持队列被挤满。用户会随机登出,密码重置也不再生效,还有客户在一天内提交了三份工单,每份都描述着同样的矛盾状况——他同时处于已登录和未登录的状态。工程师们花了四小时处理那份工单,解码他的令牌并检查其有效期,结果一切看起来都正常。
这类漏洞通常存在于各种机制的衔接处,而每一项新机制的添加都是为了解决上一项机制存在的缺陷。
为何“无状态”很难真正保持无状态
JWT的标准宣传点很有吸引力:无需会话存储,也无需查询数据库——只需将用户ID放入载荷中,对其进行签名后交给客户端,让令牌本身成为权威依据。
问题在于令牌的撤销。已签名的令牌在过期前始终有效,而服务器本身没有内置功能可以提前取消其效力。要在所有地方实现用户登出、应对令牌被盗的情况,或在密码更改后取消访问权限,都需要使用令牌黑名单,而这恰恰是该设计原本旨在消除的那种共享状态。
在上述场景中,团队最终不得不使用 Redis 来维护已撤销令牌的黑名单,另外还用一个独立的存储来管理刷新令牌,并通过自定义中间件层将它们连接起来。原本因无状态特性而被采用的系统,反而成了架构中最具状态性的组件。
每次请求检查所带来的后果
在每个请求中运行的中间件简化版本如下。它从Authorization头部获取承载令牌,然后在Redis黑名单中查询该令牌,最后使用jwt.verify验证签名与声明内容。
// verify access token
function verifyToken(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "No token" });
// check if revoked
const revoked = await redis.get(`revoked:${token}`);
if (revoked) return res.status(401).json({ error: "Token revoked" });
jwt.verify(token, process.env.SECRET, (err, decoded) => {
if (err) return res.status(401).json({ error: "Invalid token" });
req.user = decoded;
next();
});
}
按照当前的写法,该函数根本无法运行:它使用了await却未声明为async。更严重的问题在于架构设计——现在每个请求都要向Redis发起网络调用,这样一来JWT原本避免查询的优势就消失了。除此之外,还需要承担验证令牌的CPU开销,以及再次检查令牌有效期和声明内容的额外处理。
错误就隐藏在这三次检查之间的缝隙中:
- 访问令牌过期后,刷新操作会默默失败。
这些问题都需要在不同的层面进行修复,而且在一个层面上所做的修改很容易打破另一个层面的假设。
用一个会话存储替代三种机制
另一种方案是服务器端会话。客户端仅通过Cookie持有一个不可见的会话ID,而会话数据则存储在数据库中。在Express框架中,使用express-session配合connect-pg-simple存储引擎可将会话数据保存在Postgres数据库中:
const session = require("express-session");
const PgSession = require("connect-pg-simple")(session);
app.use(session({
store: new PgSession({ pool: db }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
}
}));
有一些设置负责承担大部分安全工作。httpOnly可防止页面中的JavaScript访问该cookie,从而避免通过XSS窃取令牌。secure确保该cookie仅通过HTTPS传输。resave: false可避免在每次请求时都重新写入未更改的会话信息,而saveUninitialized: false则可防止存储空间被匿名访问者的空会话占满。maxAge参数决定了会话的有效期为七天。需要注意的是,connect-pg-simple要求会话表必须存在,因此需在迁移过程中创建该表。
之后的操作就变得相当简单了:
- 登录时会设置
req.session.userId。 - 登出时会调用
req.session.destroy()。 - 强制登出则会从会话表中删除该用户的记录。
现在撤销操作只需一次删除,无需担心黑名单过期导致不同步,且每个请求都会读取相同的权威数据行。还有两个加强措施值得采用:登录时调用req.session.regenerate()以防止会话固定攻击,同时在Cookie上设置sameSite属性(必要时还需添加CSRF保护),因为浏览器会自动发送Cookie,而请求头则不会。
在本文描述的场景中,此次迁移删除了大约340行认证逻辑代码,每删除一行就意味着漏洞藏身的可能性减少一个。
衡量变化程度
为公正评估这一变更,团队在迁移前后的90天内分别统计了所有与认证相关的漏洞报告。他们得到的数据如下:
Before (JWT):
- Auth bugs reported: 43
- Avg debug time per bug: 2.4 hours
- Force-logout capability: not possible
After (Sessions):
- Auth bugs reported: 13
- Avg debug time per bug: 0.6 hours
- Force-logout time: < 1 second (delete row)
Reduction: ~70%
错误报告数量从43降至13,减少了约70%,每条错误的平均调试时间也从2.4小时降到了0.6小时。强制登出功能从无法实现变为只需不到一秒即可删除对应记录。这些数据来自某个团队,而非通用基准。
常见的反对意见是每次请求都会进行数据库查询。对于大多数应用而言,读取会话表的主键信息相比普通请求已执行的查询来说成本很低,因此真正的瓶颈几乎总是在其他地方。
两种请求路径的对比
将两者并列对比,差异十分明显。JWT路径需要在路由处理程序执行之前,由服务器解码令牌、向Redis查询黑名单列表,并验证令牌的有效期及声明内容:
Client --> [Token in header] --> Server
|
Decode token (CPU)
|
Check Redis blocklist (network)
|
Validate expiry + claims
|
Route handler
会话路径则是由服务器从Postgres中读取会话信息,然后再将其传递给路由处理程序:
Client --> [Session cookie] --> Server
|
Lookup session in Postgres
|
Route handler
由于只需查询一个权威数据源,因此一旦出现问题,只需在唯一一处查找原因。
JWT的适用场景
这些因素并不会使JWT变得糟糕。它们非常适合用于那些有效期短、有特定用途的令牌:邮件验证链接、密码重置流程,以及其他在几分钟内就会过期且无需在传输过程中撤销的独立意图证明。如下所示的验证令牌会包含用户ID及其用途,并在15分钟后过期:
// email verification — expires fast, never needs revocation
function makeVerifyToken(userId) {
return jwt.sign({ userId, purpose: "verify" }, process.env.SECRET, {
expiresIn: "15m"
});
}
添加purpose字段非常重要,因为它能防止在期望其他类型令牌的地方接受验证令牌。需记住,仅靠JWT无法保证单次使用;如果重置链接只能使用一次,就必须记录其已被使用,或者将令牌与使用后会变化的值绑定,比如当前密码的哈希值。
规范本身从来不是问题所在。问题在于人们试图将其扩展到用于持久、可撤销且寿命较长的用户会话,而这本就是会话设计的目的。
为何团队仍坚持使用复杂的认证方式
会话技术显得过时,而JWT则被视为现代方案,而且关于JWT的教程数量远远超过会话技术的教程。这种不平衡影响了人们对何种方式才是正确的认知。
经验丰富的工程师往往反而会选择乏味的解决方案。大量的生产事故让他们明白,那些简单的系统很少会在夜间触发警报,而且早在 Redis 出现之前,服务器端会话就已经可靠地运行了几十年。
选择你的方案
- 正在开发新的 Web 应用?建议使用服务器端会话:设置
httpOnly、secure、sameSite属性的 Cookie,将会话数据存储在 Postgres 中,登录时重新生成会话,登出时销毁。如需更全面的对比,可参阅我们的指南 会话与 JWT 的选择。 - 已经在使用运行良好的 JWT 方案?无需更改;仅仅为了迁移而迁移反而会增加风险。
最佳的工程决策往往是那些能够减少代码量的方案。更少的代码行意味着更少的漏洞可能,而更少的层次结构则能让剩余的漏洞更快被发现。