每个 Web 应用都要做认证,而认证机制的选择往往被简化为"JWT 还是 Session"。这个决定比看起来重要。选错可能导致:无法即时撤销被盗 token、敏感数据意外暴露、微服务间鉴权复杂度爆炸,或者简单的 Web 应用被塞进不适合的无状态架构。
这篇指南拆解两者的工作原理、安全特性和适用场景,帮你避开常见陷阱。
Session Cookie:服务端记忆
Session 是经典的认证方式。流程:
- 用户登录,服务端验证凭据
- 服务端创建一个 session 对象,存到内存、Redis 或数据库
- 服务端把 session ID(一个随机字符串)通过
Set-Cookie发给浏览器 - 后续请求自动带上这个 cookie,服务端用 ID 查 session 存储
- 登出时,服务端删除 session 对象
// Express + express-session 的典型用法
const session = require('express-session');
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, sameSite: 'lax' }
}));
app.post('/login', (req, res) => {
// 验证用户...
req.session.userId = user.id;
res.send('logged in');
});
app.post('/logout', (req, res) => {
req.session.destroy(() => res.send('logged out'));
});
关键特性:
- 服务端状态:每个活跃 session 占用存储(内存或外部存储)
- 可即时撤销:从存储里删掉就立即失效,无需等待过期
- Cookie 自动管理:浏览器处理发送,跨域要配置 SameSite
- 数据不暴露给客户端:cookie 里只是个 ID,业务数据留在服务端
JWT:自包含 token
JWT(JSON Web Token)把认证状态编码在 token 本身里。结构是三段用 . 分隔的 Base64URL:
header.payload.signature
- header:算法类型(如
{"alg":"HS256","typ":"JWT"}) - payload:声明(claims),如用户 ID、角色、过期时间
- signature:用密钥对
header.payload的签名,防止篡改
// 生成 JWT
const jwt = require('jsonwebtoken');
const token = jwt.sign(
{ userId: 42, role: 'admin' },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// 验证 JWT
const payload = jwt.verify(token, process.env.JWT_SECRET);
关键特性:
- 无状态:服务端不需要存储 token,签名验证就足够
- 自包含:payload 里有用户信息,不用每次查库
- 跨域友好:放在
Authorization: Bearer ...header 里,不受 cookie 跨域限制 - 难以撤销:在过期前,签发的 token 一直有效
核心权衡
两者的设计哲学是相反的。Session 把信任放在服务端,JWT 把信任放在 token 里。这导致一系列不同的取舍:
| 维度 | Session Cookie | JWT | |------|----------------|-----| | 状态 | 服务端有状态 | 完全无状态 | | 撤销 | 立即生效 | 需要黑名单或短 TTL | | 跨服务 | 共享 session 存储 | 验签就能用 | | 跨域 | 需要 cookie 配置 | header 即可 | | 大小 | cookie ~20 字节 | token ~500 字节 | | 安全默认值 | 较高(HttpOnly cookie) | 取决于实现 | | 数据保密 | 服务端私有 | payload 客户端可读 |
最后一行是最容易被忽略的陷阱。
JWT 的常见安全陷阱
1. payload 不是加密的,只是 Base64 编码
任何人拿到 JWT 都能解出 payload——把它粘进 JWT 解码器,每一条声明都一览无余。把敏感信息(密码、API key、个人信息)放进 payload 等同于明文泄露:
# 任何 JWT 都能这样解(无需密钥)
echo "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ" | base64 -d
# {"userId":42,"role":"admin"}
要保密,用加密的 JWE(JSON Web Encryption),或者干脆别把敏感数据放进 JWT。
2. alg: none 攻击
早期 JWT 库允许 alg: none 头,意味着签名被跳过。攻击者可以构造任意 token 并声称不需要验证。现代库默认拒绝,但配置不当(比如允许从 header 读取 alg 而不限制白名单)仍会触发。
// 危险:允许任何算法
jwt.verify(token, secret); // 一些老版本库的默认行为
// 安全:显式指定算法
jwt.verify(token, secret, { algorithms: ['HS256'] });
3. 无法即时撤销
JWT 在过期前一直有效。如果用户登出后 token 被截获,攻击者仍能使用。解决方法:
- 短 TTL:access token 15 分钟过期,配合 refresh token
- 黑名单:服务端维护撤销列表(但这就破坏了"无状态"的初衷)
- 密钥轮换:定期换密钥,让所有旧 token 失效(影响所有用户)
4. 存储位置
JWT 放在 localStorage 容易被 XSS 偷走。放在 HttpOnly cookie 里安全一些,但又要处理 CSRF。没有完美方案,看你优先防哪种攻击。
何时选 Session
- 传统 Web 应用:服务端渲染、同源、需要即时登出
- 强安全要求:金融、医疗,任何需要"立刻踢人下线"的场景
- 简单系统:单体服务,没有跨服务鉴权需求
- 数据不应被客户端看到:session 数据完全在服务端
何时选 JWT
- 微服务架构:各个服务只需要验签就能识别用户,无需共享 session 存储
- SPA + 移动 API:客户端管理 token,跨域、跨平台
- 第三方 API 调用:服务间调用,token 自包含身份信息(可以用 JWT 生成器 快速构造)
- 短 TTL 场景:配合 refresh token,access token 短期有效
混合方案
实际项目常常混用。常见模式:
- JWT 用于服务间调用,Session 用于浏览器:API 网关验证 JWT,但用户登录用 session
- Refresh token + Access token:refresh token 是有状态的(可撤销),access token 是无状态 JWT(短 TTL)
- Session ID 换 JWT:登录后服务端发 JWT,但 token 列表存在 session 里,可批量撤销
在本地解码和生成 JWT
无论你是在调试 token 内容、验证签名,还是测试 JWT 生成逻辑,都应该避免把真实 token 发到陌生服务。
JWT 解码器 和 JWT 生成器 都在浏览器本地完成所有操作。token 的 header 和 payload 在你的浏览器里解码,签名验证用 Web Crypto API 在本地完成。你的 token(尤其是生产环境的 token,可能包含用户 ID 和权限信息)不会被发送到任何服务器。
# 命令行快速解码 JWT payload(无需密钥)
echo "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ" | base64 -d 2>/dev/null || \
echo "eyJ1c2VySWQiOjQyLCJyb2xlIjoiYWRtaW4ifQ==" | base64 -d
总结:传统 Web 应用优先 session;微服务、跨平台 API 优先 JWT(配短 TTL)。永远不要把敏感数据放进 JWT payload。需要即时撤销就别用纯 JWT,要么用 session,要么用 refresh token 配黑名单。