成为全栈·Node 后端篇·认证方案:JWT 还是 Session

成为全栈·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.tsauthMiddleware,它验完令牌,直接 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)。

这种"把笼统错误拆细"的做法,既方便了前端做差异化体验,也收紧了安全(不暴露账号是否存在)。错误码机器化在这里不是形式主义,是真的在挡攻击 契约先行

六、小结与前瞻

认证选型没有银弹,只有权衡:

  1. 本质差异:Session 是"服务端记你是谁"(有状态),JWT 是"你带身份证明、服务端验章"(无状态)。
  2. 无状态代价:JWT 难吊销,要么维护黑名单、要么短有效期。
  3. P-22 :我们选 JWT 短效访问令牌(默认 1h、无状态、易扩展)+ 有状态刷新令牌(refresh_tokens 表,可旋转/吊销/登出作废),各取一半优势。
  4. P-23 :JWT 载荷塞 role,鉴权零查库;代价是角色变更要等令牌过期或强制重登才生效。
  5. P-24 :401 拆成 1001/1002/1003/1004/1005 五个专用码;1001 不暴露账号是否存在,防枚举。
  6. 安全底线: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

相关推荐
FungLeo6 小时前
成为全栈·Node 后端篇·文章 CRUD 与投稿状态机
node.js·状态机·crud·成为全栈
晴天169 小时前
Webpack3/4/5 核心差异、性能升级与迁移实战指南-Day38
webpack·node.js
大牧师9 小时前
Nest.js 微服务入门教程
开发语言·javascript·后端·微服务·node.js·nest.js·nest
wxwx_bscxy32220 小时前
NodeJS 高校学业预警系统10551
mysql·node.js·vue·高校学业预警
FungLeo1 天前
成为全栈·Node 后端篇·列表接口三件套:分页、筛选、排序
node.js·列表排序·列表分页·成为全栈·列表接口设计·列表筛选
晴天161 天前
浏览器中ESM与AMD模块共存的解决方案
前端·node.js
晴天161 天前
Node.js 模块化混合开发指南:CommonJS / ESM 混用适配方案与落地配置
node.js
FungLeo1 天前
成为全栈·Node 后端篇·分类与标签:多对多关系的建模与查询
node.js·成为全栈·分类与标签·多对多关系建模·多对多关系查询
晴天161 天前
ES 标准、V8 引擎与 Node.js 版本联动关系全解与实战踩坑
大数据·elasticsearch·node.js