深蓝黑背景封面。左侧两个并列面板,中间一个紫色 VS 圆标:左面板灰色描边「Session 会话」,客户端登录后,服务端盒子里画着一张「会话表:sessionID → 用户」,注「每次请求都要查表,服务端存状态」;右面板青色描边「Token 令牌」,客户端自持一张「header.payload.signature」令牌卡片,服务端盒子里写「只验签 + 查过期,不查库」,注「服务端无状态」。底部一行注「session 把状态留在服务端,token 把状态签进令牌随身带」。右侧大标题「token 鉴权和 session 验证,到底差在哪」,副标题「状态、分布式、撤销、安全逐项对比」。左上标注「面试经典 · Web 安全」,右上胶囊标注「Day 04 · 11-21」。 图 1:一个把状态留在服务端,一个把状态签进令牌随身带。

先说明属性:这是一篇经典题解析,题目出自本地历史面经资料整理,与任何公司的当前招聘无关。「基于 token 的后台鉴权和用户名密码加 session 有什么具体不同」是后端面试的经典对比题。模型 API 网关的 API Key、JWT 设计也直接复用这套框架。差别不止「一个存服务端一个存客户端」一句话,要按状态、分布式、撤销、安全四个维度逐项拆开。

先把两套流程各走一遍。

Session 流程:客户端提交账密,服务端验证通过后建一条会话记录(sessionID 映射到用户),存进内存或外部存储,把 sessionID 通过 Cookie 下发;之后每个请求带 sessionID,服务端查表比对,命中即放行。状态在服务端,会话表是 single source of truth。

Token 流程:登录验证后,服务端用密钥把用户信息、签发时间、过期时间签成一张令牌返回,客户端自持(常见放 Authorization 头);之后每个请求带令牌,服务端只做两件事——本地验签、查过期,不查任何存储。以 JWT 为例,RFC 7519 定义的令牌就是 header.payload.signature 三段:头部声明算法,载荷装声明(用户、过期时间等),签名保证前两段没被改过。

逐项对比,差异才真正显形。

状态保持:session 有状态,服务端要为每个在线用户留一条记录;token 无状态,验证纯靠计算,服务端零存储。这一条决定了后面所有差异。

分布式适配:session 方案上多机就麻烦——请求落到没存这条会话的机器上就不认,要么粘滞会话绑死机器,要么引入共享存储把会话表集中,多一个组件多一份运维。token 天然水平扩展:任何一台机器拿同一个密钥验签即可,扩容缩容无感。这也是微服务和开放 API 场景几乎一边倒选 token 的原因。

撤销能力:这是 token 的短板,也是面试的区分点。session 想踢人,删一条记录立即生效;token 签发后到过期前都有效,服务端想提前作废只能额外维护黑名单(又引回了状态),或者把过期时间压短、配合刷新令牌轮换。所以「token 无状态」和「可撤销」本质上是 trade-off,设计时要在过期时长上找平衡。

安全面:sessionID 走 Cookie,浏览器自动携带,要防 CSRF(配合 SameSite、校验来源);token 手动放请求头,不受 CSRF 影响,但若存 localStorage 就要防 XSS 偷令牌。另外 token 的载荷只是编码不是加密,敏感信息不能往里放——签名只保证没被篡改,不保证别人看不见。

代码实测:签发、验签、篡改、过期。

下面用 Node.js 22.15.0 内置 crypto 模拟 HMAC 签名的令牌全流程(node token.js):

const crypto = require('crypto');
const SECRET = 'demo-secret-key';
const b64u = o => Buffer.from(JSON.stringify(o)).toString('base64url');
function sign(payload) {
  const body = `${b64u({ alg: 'HS256', typ: 'JWT' })}.${b64u(payload)}`;
  const sig = crypto.createHmac('sha256', SECRET).update(body).digest('base64url');
  return `${body}.${sig}`;
}
function verify(token) {
  const [h, p, s] = token.split('.');
  const expect = crypto.createHmac('sha256', SECRET).update(`${h}.${p}`).digest('base64url');
  if (s !== expect) return { ok: false, reason: '签名不符' };
  const payload = JSON.parse(Buffer.from(p, 'base64url').toString());
  if (payload.exp * 1000 < Date.now()) return { ok: false, reason: '已过期' };
  return { ok: true, payload };
}
const now = Math.floor(Date.now() / 1000);
const t = sign({ sub: 'user-42', role: 'admin', iat: now, exp: now + 3600 });
console.log('正常验签:', JSON.stringify(verify(t)));
const [h, , s] = t.split('.');
const forged = `${h}.${b64u({ sub: 'user-42', role: 'guest', iat: now, exp: now + 3600 })}.${s}`;
console.log('篡改后验签:', JSON.stringify(verify(forged)));

输出:

正常验签: {"ok":true,"payload":{"sub":"user-42","role":"admin",...}}
篡改后验签: {"ok":false,"reason":"签名不符"}

注意验签全程没碰任何数据库——这就是无状态的含义;攻击者改掉 payload 里的角色,签名立刻对不上。

深蓝黑背景概念图,左右两条时序流程对比。左侧「Session 流程(有状态)」:客户端与服务端两条生命线,①客户端发登录账密到服务端;②服务端内部建会话、把 sessionID 存进会话表;③服务端下发 sessionID 给客户端;④之后每次请求携带 sessionID;⑤服务端查表比对,命中即放行。右侧「Token 流程(无状态)」:①客户端发登录账密;②服务端用密钥签名签发 token,内含用户与过期时间;③返回 token,客户端自持;④每次请求在 Authorization 头携带 token;⑤服务端本地验签并查过期,不查库,该步骤以青色高亮。底部总结条:代价对比 —— session 贵在服务端存储与多机同步,token 贵在撤销,只能等过期或维护黑名单。 图 2:两条时序对照,高亮的那一步就是「不查库」。

怎么选型。

单体应用、后台管理系统、需要强踢人能力:session 简单直接。多实例微服务、开放 API、跨域移动端:token 更合适。现实系统也常混用——对外 API 用 token,内部管理后台用 session。更全面的方案选型(含 OAuth2 授权)是另一篇的题材,这里只把两种机制本身讲透。

答题建议:先各用一句话概括两种流程,然后按状态、分布式、撤销、安全四维度列对比,主动指出「撤销是 token 的代价」,最后给选型结论。你被问过「token 怎么踢人下线」这个追问吗?当时怎么答的?评论区见。