成为全栈·Node 后端篇·认证方案:JWT 还是 Session
用户打开你的网站,发了一条"删除这篇文章"的请求。后端第一件事就得回答:"你是谁?你有权删它吗?" 这套"确认身份 + 确认权限"的机制,就是认证与授权,是后端最外的一道门,也是安全的第一道防线。
这一篇聊认证方案的选型,焦点是那个被聊烂了的问题:JWT 还是 Session?但我不打算给你"JWT 更好"这种偷懒结论,而是把两者的真实代价摊开,再讲我们为什么选了"折中路线"。

一、JWT 与 Session 的本质差异
先抛弃名词,看它们到底怎么干活。
Session 方案 :用户登录成功,服务端生成一条 session 记录(存在内存或 Redis),返回给客户端一个 sessionId(通常塞在 Cookie 里)。之后每次请求,客户端带上 sessionId,服务端查"这个 id 对应哪个用户"。状态在服务端。
JWT 方案 :用户登录成功,服务端用密钥给一段"身份声明"签名,生成 token 返回(客户端存 localStorage 或 Cookie)。之后每次请求带上 token,服务端用密钥验签名 就知道身份,不查任何存储 。状态在令牌里。
一句话区分:Session 是"服务端记着你是谁",JWT 是"你自己带着身份证明,服务端只验章"。
二、无状态的代价:JWT 最难的是"吊销"
JWT 最大的卖点是"无状态"------服务端不用存会话,水平扩展时不用共享 session 存储,特别适合我们这种"要双部署、要 Serverless/边缘"的场景。但它有个绕不开的硬伤:令牌一旦签发,在过期前一直有效,服务端想吊销它很难。
想象用户登出了,或者账号被盗需要紧急封禁。Session 方案简单------删掉服务端的 session 记录,下次请求立刻失效。JWT 呢?令牌还在有效期内,服务端验签名仍然通过,你拦不住它,除非额外维护一张"吊销黑名单",或者把有效期设得极短(比如 5 分钟)。可有效期太短,用户体验又崩了。
举个具体的恐怖场景体会一下:用户的 access token 在公共 WiFi 被中间人截获。Session 方案下,管理员在后台点一下"吊销该会话",攻击者手里的 sessionId 立刻失效;JWT 方案下,只要令牌没过期,攻击者就能顶着受害者身份一直发请求------查不了、拦不住,只能干等它自然过期。这就是为什么我们死死把 access token 有效期压在 1 小时:就算最坏情况发生,攻击窗口也被锁死在这 1 小时里,而不是"永久有效直到下次重启"。
这就是选型的核心权衡:Session 吊销易、扩展难;JWT 扩展易、吊销难。没有免费的答案。
三、P-22:我们的折中------JWT 访问令牌 + 有状态刷新令牌
基于上面的权衡,我们选了一条被业界验证过的折中路线(P-22,对应评审里的"Q3 选 A"):
- 访问令牌(access token)用 JWT,短有效期(默认 1 小时),无状态。 它天天被用、量大,无状态让它扩展无痛;短命则把"吊销难"的窗口压到最小------即便泄露,最多也就 1 小时的有效期。
- 刷新令牌(refresh token)有状态,存在
refresh_tokens表里。 它的唯一使命是"access token 过期时换一个新的"。因为它存在数据库里,我们可以随时吊销、可以旋转、可以登出作废------用户登出,就删掉对应的 refresh 记录,哪怕 access token 还没过期,等它一过期就再也无法换新了。
看我们真正的令牌签发逻辑 src/shared/auth.ts:
ts
export const signAccessToken = (
payload: AccessToken,
secret: string,
expSeconds = 3600, // 默认 1 小时
): Promise<string> => sign(
{ ...payload, exp: Math.floor(Date.now() / 1000) + expSeconds },
secret,
);
export const verifyAccessToken = async (token, secret): Promise<AccessToken> => {
try {
const decoded = (await verify(token, secret, 'HS256')) as unknown as AccessToken;
if (!decoded.sub || !decoded.role || !ROLES.includes(decoded.role)) {
throw new Error('malformed token');
}
return { sub: decoded.sub, role: decoded.role };
} catch {
throw new AppError(ErrCode.TOKEN_INVALID, 401, '令牌无效或已过期'); // 1002
}
};
refresh_tokens 表(我们之前在迁移里见过)存的是 token 的哈希、归属用户、过期时间与吊销标记------明文令牌只在签发瞬间返回一次,库里只留哈希,即便数据库泄露也拿不到可用令牌。这套"有状态 refresh"让我们在享受 JWT 无状态扩展性的同时,没丢掉吊销能力。鱼和熊掌,靠分层各取一半。
四、P-23:JWT 里塞角色------"登录快照"的甜与苦
注意上面 AccessToken 的载荷里有个 role 字段:
ts
export interface AccessToken {
sub: string; // 用户 ID
role: Role; // 角色:member / editor / admin
}

把角色塞进 JWT,好处立竿见影:鉴权时不用每请求查库 。看 src/middleware/auth.ts 的 authMiddleware,它验完令牌,直接 c.set('user', { id: claims.sub, role: claims.role }),后续守卫读 user.role 就能判断权限------一次数据库查询都省了,性能友好。
但这是把双刃剑,得知道代价:JWT 里的 role 是"签发时刻的快照" 。如果用户从 member 被提升为 editor,他手里旧的 JWT 里还是 member,要等令牌过期、重新登录签发,新角色才生效。如果这是你业务不能接受的延迟(比如管理员想立刻给某人加权限),就得在角色变更时强制该用户重新登录、或主动吊销其 refresh token 迫使其换新。
所以"角色进 JWT"是一个明确的 trade-off 取舍:用"角色变更的短暂延迟"换"每次鉴权零查库"。对我们的内容平台,角色调整是低频管理动作,延迟几小时完全可接受,这笔买卖很划算。
有个真实例子帮你体会这个延迟:你是 admin,下午三点把一个活跃作者从 member 提成了 editor,希望他马上能审稿件。但他上午登录领的 JWT 里 role 还是 member,此刻去审稿会被守卫按 403 拦下------得等他那个 JWT 过期(或主动退出重登)重新签发,新角色才生效。对内容平台这无所谓;但假如你做的是"管理员一键封禁违规用户"这种要"立刻生效"的动作,就不能只靠 JWT 里的快照,得配合"封禁即吊销其 refresh token"的主动机制。角色进 JWT,省的是性能,牺牲的是实时性,你得心里有数。
顺带说下签名算法:我们用的 HS256 是对称 签名------同一把 JWT_SECRET 既签名又验签。它简单、快,适合我们这种单租户、签发验签都在自己后端的场景。如果你的系统要让第三方(比如另一个服务)只验不签,就得换成 RS256 这种非对称算法(私钥签名、公钥验签)。但对这个专栏后端,HS256 够用且零额外依赖(直接复用 Hono 内置的 hono/jwt),不必过度设计。
五、P-24:401 不是"一个码",我们拆成了五个
最后讲一个安全细节(P-24),也是《契约先行》里错误码机器化的直接体现。很多项目把所有认证失败都返回笼统的 401,但我们在契约里把 401 拆成了五个语义不同的业务码:
1001用户名或密码错误 :登录时凭证不对。关键设计是------不区分"用户不存在"还是"密码错",统一返回 1001。否则攻击者就能用不同用户名试探"哪个账号存在",做账号枚举攻击。1002令牌无效或已过期:access token 校验失败(签名错、篡改、过期)。1003刷新令牌失效:refresh token 被旋转过、已作废、或重放------这是有状态 refresh 才能识别的细粒度状态。1004令牌缺失 :请求根本没带Authorization头(这是 HTTP 层的判断,留在路由里)。1005账号被禁用:账号本身被关了,连登录都不行。
看 authMiddleware 里:缺令牌抛 1004,令牌无效抛 1002------同样是 401 的 HTTP 状态,业务码精确告诉前端"是哪一类问题",前端据此决定是弹"请登录"(1002/1004)、静默刷新(1003)、还是跳"账号异常"(1005)。
这种"把笼统错误拆细"的做法,既方便了前端做差异化体验,也收紧了安全(不暴露账号是否存在)。错误码机器化在这里不是形式主义,是真的在挡攻击 契约先行。
六、小结与前瞻
认证选型没有银弹,只有权衡:
- 本质差异:Session 是"服务端记你是谁"(有状态),JWT 是"你带身份证明、服务端验章"(无状态)。
- 无状态代价:JWT 难吊销,要么维护黑名单、要么短有效期。
- P-22 :我们选 JWT 短效访问令牌(默认 1h、无状态、易扩展)+ 有状态刷新令牌(
refresh_tokens表,可旋转/吊销/登出作废),各取一半优势。 - P-23 :JWT 载荷塞
role,鉴权零查库;代价是角色变更要等令牌过期或强制重登才生效。 - P-24 :401 拆成 1001/1002/1003/1004/1005 五个专用码;
1001不暴露账号是否存在,防枚举。 - 安全底线:refresh token 库里只存哈希,明文只返回一次。
下一篇({{LINK:M1-13}})我们顺着认证往下走,把"注册登录全流程"完整实现一遍:注册为什么强制 member、密码怎么哈希、登录怎么发双令牌、以及那个"首个 admin 怎么来"的死锁怎么解。
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
