D14 · JWT vs Session 认证
对应主课: L22 JWT 认证 实现对照: 课程 Express 5 + jsonwebtoken 9 登录流程 最后核对: 2026-09-23
1. 核心区别
JWT 是承载声明的令牌格式,Session 是管理会话的方式。两者并非完全互斥:应用可以用服务端记录管理会话,同时签发短期 JWT;Cookie 则是浏览器保存、发送数据的机制,既能携带随机会话 ID,也能携带 JWT。
下面比较“服务端会话 ID”与“验证自包含 JWT”两条常见路径:
| 维度 | 服务端会话 ID | 自包含 JWT |
|---|---|---|
| 权威信息 | 会话存储中的记录 | 令牌声明加验证规则;可叠加服务端状态 |
| 客户端保存 | 通常只保存随机 ID | 保存整个令牌,位置另行设计 |
| 多实例部署 | 会话查询需要一致的共享状态或路由策略 | 验签方需可信密钥与相同验证规则;撤销、角色更新仍需协调 |
| 注销 | 撤销会话记录,并清客户端凭证 | 删除客户端副本不撤销已签发令牌;可增加撤销记录或版本校验 |
| 大小 | 通常 ID 较短 | 声明越多,令牌越大,没有固定大小优势 |
| XSS / CSRF | 取决于保存、发送方式及应用防护 | 同样取决于保存、发送方式,格式本身不消除风险 |
L22 的 JWT 中间件验证签名与有效期,管理员接口还会查询当前用户角色,profile 与 refresh 也查询用户。课程代码已经不是“用 JWT 就从不查数据库”的模型。
2. JWT 结构
本课程使用签名后的 JWS 紧凑形式:
base64url(header).base64url(payload).base64url(signature)Header 例如:
{ "alg": "HS256", "typ": "JWT" }L22 的 payload 包含 userId、kind,由库加入 iat 和 exp。exp 使用 Unix 秒数,不是 JavaScript 的毫秒时间戳。HS256 对编码后的 header、分隔点与 payload 计算 HMAC,再编码签名;Base64URL 与普通 Base64 的字符和填充约定不同。
这个签名形式不隐藏 payload。JWT 也存在使用 JWE 加密的形式,不能把“JWT 永远不加密”当成整个规范的定义。即使有加密,也应只放必要声明,避免把密码和签名密钥装进令牌。
解码不等于验证。 验证端必须固定允许的算法、选择可信密钥,并检查用途与适用的时间、签发者、受众等声明;不能相信客户端自称的 role,也不能让 refresh token 通过 access token 的验证规则。RFC 8725
3. JWT 的生命周期
3.1 撤销需要额外机制
如果验证端只验签名和过期时间,令牌会在有效期内继续被接受。修改密码或点击退出,不会自动改变已签发令牌的签名。可以按业务需求增加:
- 短期 access token,缩短泄露后可能被使用的窗口,但不提供即时撤销。
- 按
jti、会话或令牌族记录撤销状态,校验时查询。 - 服务端保存版本号,并在验证时比较;仅保存而不检查没有作用。
这些方案重新引入状态查询与一致性要求。撤销 refresh token 只阻止继续换取凭证,已经发出的 access token 是否立即失效,取决于 access 验证是否也检查会话状态。
3.2 双 Token 策略
L22 使用不同密钥和 kind 区分两类令牌:access token 为 15 分钟,refresh token 为 7 天。这是课程选值,不是所有应用都必须遵守的标准时长;实际取值还取决于权限、风险与重新认证要求。
并非每个 401 都是过期;刷新失败或重试仍是 401 时应结束,不能无限循环。L22 用单标签页共享 Promise 合并并发刷新,登录/退出的会话版本阻止迟到响应恢复旧账号。跨标签竞争和服务端撤销需要另外实现。
3.3 存储位置选择
| 位置 | 页面脚本可否读取 | 浏览器是否自动携带 | 仍需处理的问题 |
|---|---|---|---|
| localStorage | 同源脚本可以读取 | 不自动作为 HTTP 凭证发送 | XSS 可窃取;持久化扩大暴露时间 |
| HttpOnly Cookie | 脚本不能直接读取该 Cookie | 按 Cookie 与请求规则携带 | XSS 仍可代发请求;需要 CSRF 设计 |
| 内存变量 | 在相应脚本上下文中使用 | 不自动发送 | 页面中的恶意代码仍可能使用凭证或发请求;刷新后状态丢失 |
HttpOnly、Secure、SameSite 分别处理脚本直接读取、HTTPS 传输限制和部分跨站携带规则。它们不能互相替代,也不能消除 XSS。Web 应用可优先评估服务端会话或 BFF 配合 HttpOnly Cookie,令牌保留在服务端;采用浏览器令牌方案则要明确威胁与刷新协议。OWASP 不建议把认证凭证存入 Web Storage。会话管理建议
L22 为展示单标签页流程把两个 token 存在 localStorage,这是有明确限制的教学实现。下面的实验独立运行,L22 的登录、刷新接口仍保持原来的请求协议。
4. 按约束选择
| 需求 | 应先回答的问题 |
|---|---|
| SPA 与自有 API | 是否能采用同站会话 / BFF?浏览器是否必须直接持有 token? |
| 多个资源服务 | 谁签发、谁验证,如何分发密钥并约束 issuer / audience? |
| 即时注销 / 权限变更 | 哪些接口会查询会话或撤销记录,缓存多久生效? |
| 原生客户端 | 如何使用系统安全存储,如何处理丢失设备与重新认证? |
| 第三方授权 | 采用哪种 OAuth / OIDC 流程,而不是只选一种 token 字符串格式? |
OAuth 是授权协议体系,access token 可以是不透明字符串,也可以采用 JWT。课程自建登录、刷新端点不等于已经实现 OAuth。
5. 动手实验:解码与验签的区别
在 L22 的 server/ 中创建 scripts/jwt-demo.cjs,执行 node scripts/jwt-demo.cjs。它使用本次进程生成的临时密钥和演示数据,不读取实际登录凭证,也不调用业务接口。
// server/scripts/jwt-demo.cjs
const assert = require('node:assert/strict')
const { randomBytes } = require('node:crypto')
const jwt = require('jsonwebtoken')
const secret = randomBytes(32)
const token = jwt.sign(
{ userId: 'demo', name: '示例用户', kind: 'access' },
secret,
{ algorithm: 'HS256', expiresIn: '1m' },
)
const [header, payload, signature] = token.split('.')
const decodePart = part => JSON.parse(Buffer.from(part, 'base64url').toString('utf8'))
console.log('可直接解码的 Header:', decodePart(header))
console.log('可直接解码的 Payload:', decodePart(payload))
const verified = jwt.verify(token, secret, { algorithms: ['HS256'] })
assert.equal(verified.name, '示例用户')
// 保留原签名,改变负载;解码能成功,验签必须失败
const changed = { ...decodePart(payload), userId: 'another-user' }
const changedPart = Buffer.from(JSON.stringify(changed)).toString('base64url')
const forged = `${header}.${changedPart}.${signature}`
assert.equal(decodePart(changedPart).userId, 'another-user')
assert.throws(() => jwt.verify(forged, secret, { algorithms: ['HS256'] }))
assert.throws(() => jwt.verify(token, randomBytes(32), { algorithms: ['HS256'] }))
console.log('修改负载或使用错误密钥均未通过验签')示例刻意只验证签名实验;业务接口还需像 L22 那样检查声明形状、用途和权限。不要把演示中的 userId: 'demo' 当作课程 MongoDB 用户 ID。
6. XSS 与 CSRF 分别影响什么
XSS:页面中执行了不受信任的脚本
脚本能读取同源 localStorage,也可能以当前页面身份发请求。把 refresh token 放进 HttpOnly Cookie,只阻止直接读出这份 Cookie;恶意代码仍可能调用刷新接口并读取返回的 access token,或直接调用业务接口。因此还需要按实际渲染方式防止脚本注入,不能把 Cookie 属性当作完整防线。
CSRF:利用浏览器自动携带凭证
若接口依赖自动携带的 Cookie,来自其他站点的请求在满足相应 Cookie 规则时也可能带上它。攻击能否成功还取决于 SameSite、请求方式、Origin 校验、CSRF token 和服务端接口行为,不能简化成“跨站表单必然转账成功”。
SameSite 的 site 不等于 origin;同站子域也可能跨源。SameSite 通常作为纵深措施,与应用需要的 CSRF token、来源检查等组合;状态变更不应设计成 GET。CORS 控制某些跨源访问和响应读取,不能单独代替 CSRF 防护。OWASP CSRF 防护
区分各层职责
7. Token 轮换(Rotation)
轮换需要在消费旧 refresh token 的同时,使它失效并签发后继 token,还要保留关联以识别旧 token 重用。两次并发刷新若都能先“查到未使用”、再分别标记,就没有实现原子消费。RFC 9700 的 OAuth 安全建议包含轮换与重放检测要求;这里借用该机制解释原理,不声称课程自建登录符合完整 OAuth 规范。RFC 9700 §4.14.2
下面用不透明随机 refresh token演示状态变化。它是可运行的单进程、同步内存模型,不是 Express 路由,也不覆盖持久化、集群、Cookie、access token 撤销。保存为 rotation-demo.cjs 后运行 node rotation-demo.cjs:
// rotation-demo.cjs
const assert = require('node:assert/strict')
const { createHash, randomBytes, randomUUID } = require('node:crypto')
function createRefreshStore() {
const records = new Map()
const families = new Map()
const hash = value => createHash('sha256').update(value).digest('hex')
function issue(familyId, expiresAt) {
const raw = randomBytes(32).toString('base64url')
records.set(hash(raw), { familyId, expiresAt, used: false })
return raw
}
function start(now, lifetimeMs) {
if (!Number.isFinite(now) || !Number.isFinite(lifetimeMs) || lifetimeMs <= 0 ||
!Number.isFinite(now + lifetimeMs)) throw new Error('无效的时间参数')
const familyId = randomUUID()
families.set(familyId, { revoked: false })
return issue(familyId, now + lifetimeMs)
}
function rotate(raw, now) {
if (typeof raw !== 'string' || !raw || !Number.isFinite(now)) throw new Error('无效的输入')
const record = records.get(hash(raw))
if (!record) throw new Error('未知凭证')
const family = families.get(record.familyId)
if (!family || family.revoked || record.expiresAt <= now) throw new Error('会话已失效')
if (record.used) {
family.revoked = true
throw new Error('检测到旧凭证重用,令牌族已撤销')
}
// 本内存模型在这段同步执行期间不会与另一次调用交错
record.used = true
return issue(record.familyId, record.expiresAt)
}
return { start, rotate }
}
const store = createRefreshStore()
const first = store.start(1000, 60_000)
const second = store.rotate(first, 2000)
assert.notEqual(first, second)
assert.throws(() => store.rotate(first, 3000), /重用/)
assert.throws(() => store.rotate(second, 4000), /失效/)
const expiring = store.start(1000, 1000)
assert.throws(() => store.rotate(expiring, 2000), /失效/)
console.log('旧凭证重用后,后继凭证也被拒绝;到期凭证被拒绝')模型只存 token 哈希,保留已使用记录以检测重用,后继沿用整个令牌族的绝对截止时间。真实数据库应通过事务、条件更新或等价原子操作实现消费;还需定期清理过期记录、处理多标签并发与响应丢失后的恢复。合法客户端重试也可能触发重用检测,不能只凭一次重复就断言一定有人攻击。
L22 目前重新签发 token,但旧 refresh token 仍有效,且没有上述记录,因此尚未实现轮换。它的本地 logout 也不撤销服务端已签发凭证;L28 的 Socket 到期断开不等于完整会话撤销。
8. 从课程示例进入实际项目时检查什么
- 验证算法、密钥来源、用途及业务需要的 issuer / audience / 有效期,不使用 decode 代替 verify。
- 明确 logout、改密码、封禁账号后,已有 access 与 refresh token 分别何时失效。
- 根据客户端与部署形态选择保存、发送方式;Cookie 方案同时设计 CSRF,所有方案都要防 XSS。
- 轮换要检查原子消费、旧值失效、重用检测和令牌族关联,而不只检查返回值里有没有新字符串。
- 使用 HTTPS,限制登录等敏感接口的尝试频率,并避免把凭证写入日志、URL 或错误报告。
这是一组需要落实的设计问题,不是给教学例子贴“生产可用”标签。实际方案还应按应用权限和部署方式审查。
9. 选择之后还要定义生命周期
无论采用会话 ID 还是 JWT,都要回答谁能使用凭证、能用多久、如何刷新、何时撤销以及权限变化如何生效。令牌格式只回答其中一部分,剩余部分必须由应用协议与服务端实现。