JWT与Session对比
面试官问:"你们项目里用的是JWT还是Session?为什么这么选?"
候选人小王说:"我们用的JWT,因为它是无状态的,特别适合微服务..."
面试官追问:"那用户改密码后,你怎么让他的旧Token失效?"
小王愣了一下,说:"呃...等它过期?"
面试官继续:"如果用户被封禁,你需要立即让他下线,怎么做?"
小王开始支支吾吾...
这个场景太典型了。JWT和Session的之争,从来不是"谁更先进"的问题,而是"谁更适合你的业务场景"。
今天这篇文章,带你把两种方案彻底搞清楚。
从一个问题开始
想象你要管理一座图书馆的借阅系统:
方案A:给每本书装个追踪器
- 读者借书时,追踪器记录"谁借了哪本书、什么时候借的"
- 任何时候想查,只需要扫描追踪器
方案B:建立借阅台账
- 读者借书时,在台账上登记
- 任何时候想查,只需要翻台账
你觉得哪种方案更好?
这道题没有标准答案——图书馆的规模、借阅频率、管理需求不同,答案就不同。
JWT就是方案A(追踪器),Session就是方案B(台账)。
【直观类比】
Session:传统台账模式
想象你去酒店住宿:
- 你到前台登记,领取一张房卡
- 房卡只是个"凭证",不存储任何信息
- 酒店前台有个大台账,记录着"哪个房间谁住、什么时候入住"
- 你刷卡进门,前台查一下台账,确认你有权限
Session的工作原理一模一样:
- Cookie里的Session ID就是那张房卡
- 服务端的Session存储就是那个台账
- 验证时查一下台账,就知道"你是谁、有没有权限"
💡
Session的核心是"以我为准"——所有的状态都存在服务端,台账在我手里,信任我就好。
JWT:自包含通行证模式
想象你去演唱会:
- 你在网上买了票,票上印着你的名字、座位号、有效期
- 票本身就是个"完整凭证",不需要任何外部系统验证
- 检票员只需要看票上的信息,验一下真伪
JWT的工作原理就是这张演唱会门票:
- Token本身包含用户的身份信息和权限
- 验证方只需要验证签名,不需要查数据库
- Token流转到哪里,都能被验证
💡
JWT的核心是"自给自足"——Token自己证明自己,验证方不需要记住任何事。
两种模式的根本差异
传统台账 vs 自带防伪的票
台账(Session):
- 优点:随时可改、立即生效
- 缺点:需要同步、需要存储
自带票(JWT):
- 优点:无需存储、自包含
- 缺点:发出后难撤回、无法强制失效
Session认证原理
完整认证流程
sequenceDiagram
participant U as 用户浏览器
participant S as 服务器
participant R as Redis(Session存储)
Note over U:1. 访问登录页面
U->>S: POST /login {username, password}
Note over S:2. 验证成功,生成Session
S->>R: 设置 session:abc123 = {user_id, role, ...}
S->>U: 3. 返回Cookie: SESSIONID=abc123
Note over U:4. 后续请求自动带Cookie
U->>S: GET /api/profile Cookie: SESSIONID=abc123
Note over S:5. 验证Session
S->>R: 获取 session:abc123
R->>S: 返回用户数据
Note over S:6. 处理请求
S->>U: 返回用户信息
Note over U:7. 点击登出
U->>S: POST /logout
S->>R: 删除 session:abc123
S->>U: 清除Cookie
Session的内部结构
# 服务端存储的Session数据结构
session_data = {
"session_id": "abc123xyz",
"user_id": 10086,
"username": "zhangsan",
"role": "admin",
"login_time": "2024-01-15 10:30:00",
"ip_address": "192.168.1.100",
"user_agent": "Mozilla/5.0...",
"max_age": 7200, # 2小时有效期
"created_at": 1705289400,
"updated_at": 1705289400
}
# 存在Redis中的样子
redis.set("sess:abc123xyz", json.dumps(session_data), ex=7200)
Session的存储方案
JWT认证原理
完整认证流程
sequenceDiagram
participant U as 用户浏览器
participant A as 认证服务器
participant S as 业务服务器
Note over U:1. 登录请求
U->>A: POST /login {username, password}
Note over A:2. 验证成功,签发Token
A->>A: 生成JWT (包含用户信息)
A->>U: 3. 返回 {access_token, refresh_token}
Note over U:4. 携带Token访问API
U->>S: GET /api/profile<br/>Authorization: Bearer eyJhbG...
Note over S:5. 验证Token签名
S->>S: 使用密钥验证Signature<br/>检查 exp 过期时间
S->>U: 6. 返回用户信息
Note over U:7. Token快过期了
U->>A: POST /refresh<br/>{refresh_token}
Note over A:8. 验证RefreshToken
A->>A: 检查RefreshToken有效
A->>U: 9. 返回新的 access_token
JWT的数据结构
# JWT的Header(头部)
header = {
"alg": "HS256", # 签名算法
"typ": "JWT" # 类型
}
# JWT的Payload(载荷)
payload = {
"sub": "10086", # 用户ID
"name": "zhangsan", # 用户名
"role": "admin", # 角色
"iat": 1705289400, # 签发时间
"exp": 1705293000, # 过期时间(15分钟后)
"jti": "unique-token-id" # Token唯一标识
}
# JWT的Signature(签名)
# HMACSHA256(
# base64UrlEncode(header) + "." + base64UrlEncode(payload),
# secret_key
# )
签名的两种算法
核心对比:Session vs JWT
存储位置
性能对比
Session验证流程:
请求 → 解析Cookie → 查询Redis → 返回数据
↓
1次Redis查询
JWT验证流程:
请求 → 解析Token → 验证签名 → 检查过期 → 返回数据
↓
纯本地计算(无网络开销)
结论:JWT在验证环节没有网络开销,理论上性能更好。但Session的Redis查询通常只需要0.1~1ms,实际差距微乎其微。
扩展性对比
Session扩展(需要Session共享):
用户1 → 负载均衡器 → 服务A(查Redis)→ 返回
用户2 → 负载均衡器 → 服务B(查Redis)→ 返回
用户3 → 负载均衡器 → 服务C(查Redis)→ 返回
↓
共享同一个Redis
JWT扩展(无状态):
用户1 → 负载均衡器 → 服务A(本地验证)→ 返回
用户2 → 负载均衡器 → 服务B(本地验证)→ 返回
用户3 → 负载均衡器 → 服务C(本地验证)→ 返回
↓
不需要任何共享存储
💡
JWT的"无状态"特性让它天然适合水平扩展,但Session通过共享存储(Redis)也能很好地扩展。
撤销能力对比
这是两种方案最大的差异点:
JWT的撤销解决方案:
# 方案1:Token黑名单
# 把被撤销的Token ID存入Redis
redis.setex(f"blacklist:{jti}", ttl_remaining, "1")
# 验证时先查黑名单
def verify_jwt(token):
payload = decode_token(token)
if redis.exists(f"blacklist:{payload['jti']}"):
raise TokenRevokedError()
return payload
# 方案2:Token版本号
# 用户表增加 version 字段
user = db.get_user(user_id)
expected_version = user['token_version']
# Token Payload中包含 version
payload = decode_token(token)
if payload['version'] != expected_version:
raise TokenExpiredError()
# 用户改密码时递增版本号
db.update_user(user_id, token_version=user['token_version'] + 1)
⚠️
黑名单方案会引入网络查询,一定程度上违背了JWT"无状态"的优势。如果你需要频繁撤销,Session可能是更好的选择。
适用场景分析
什么时候选Session?
典型场景:
-企业内部管理系统(需要管理员随时封禁用户)
-金融系统(监管要求能随时冻结账户)
-多设备登录管理(需要查看/踢出特定设备)
什么时候选JWT?
典型场景:
-微服务间的API调用(服务A验证后调用服务B)
-移动端App认证(不想依赖服务端Session存储)
-第三方OAuth授权(Access Token流转给第三方)
混合方案:短期Token + Refresh Token
为什么需要混合方案?
纯粹JWT的问题:
- Token太短:用户体验差,频繁刷新
- Token太长:安全性差,被盗后损失大
纯粹Session的问题:
混合方案结合两者优点:
sequenceDiagram
participant U as 用户
participant A as 认证服务器
participant S as 业务服务器
participant R as Redis
Note over U,A:登录时
U->>A: 登录 {username, password}
A->>R: 存储 RefreshToken (7天)
A->>U: 返回 AccessToken (15分钟)
Note over U,S:正常访问
U->>S: GET /api {AccessToken}
S->>S: 验证通过
S->>U: 返回数据
Note over U,A:AccessToken过期
U->>A: POST /refresh {RefreshToken}
A->>R: 验证RefreshToken
A->>U: 返回新AccessToken
Note over U,A:主动登出
U->>A: POST /logout
A->>R: 删除RefreshToken
A->>U: 清除AccessToken
Note over A:用户被封禁
A->>R: 删除用户RefreshToken
Note over U,S:下次请求时
S->>S: AccessToken验证失败<br/>要求重新登录
Refresh Token的设计要点
# Refresh Token的特征
refresh_token_payload = {
"sub": "10086", # 用户ID
"type": "refresh", # Token类型
"device_id": "device-abc", # 设备标识(可选)
"jti": "refresh-token-id", # 唯一ID
"iat": 1705289400,
"exp": 1705894200, # 7天过期
}
# 存储在Redis中的Refresh Token
redis.setex(
f"refresh:{user_id}:{device_id}",
7 * 24 * 3600, # 7天
json.dumps({
"token_jti": "refresh-token-id",
"created_at": 1705289400,
"user_ip": "192.168.1.100",
"user_agent": "..."
})
)
# 限制每个用户的Refresh Token数量(防止多点登录滥用)
def limit_refresh_tokens(user_id, max_devices=5):
keys = redis.keys(f"refresh:{user_id}:*")
if len(keys) > max_devices:
# 删除最早的
oldest = get_oldest_token(keys)
redis.delete(oldest)
边界与特例
Token存放位置
💡
Web应用推荐:Access Token放Authorization Header,Refresh Token放HttpOnly Cookie。
移动端推荐:存放在系统级Keychain/Keystore中。
并发登录处理
需求:限制同一账号同时在线的设备数量
Session方案:
def login(user_id, session_id):
# 查找该用户已有的Session
existing_sessions = redis.smembers(f"user_sessions:{user_id}")
# 如果超过限制,删除最早的
if len(existing_sessions) >= MAX_CONCURRENT_LOGINS:
oldest = redis.zrange(f"user_login_times:{user_id}", 0, 0)[0]
redis.delete(f"sess:{oldest}")
redis.srem(f"user_sessions:{user_id}", oldest)
# 创建新Session
redis.sadd(f"user_sessions:{user_id}", session_id)
redis.zadd(f"user_login_times:{user_id}", {session_id: time.time()})
JWT方案:
def login(user_id):
# 检查当前Refresh Token数量
current_tokens = redis.keys(f"refresh:{user_id}:*")
if len(current_tokens) >= MAX_CONCURRENT_LOGINS:
# 删除最早的
oldest = redis.zrange(f"user_refresh_times:{user_id}", 0, 0)[0]
redis.delete(oldest)
redis.zremrangebyscore(f"user_refresh_times:{user_id}", 0, oldest_score)
# 签发新Token
access_token = create_access_token(user_id)
refresh_token = create_refresh_token(user_id, device_id)
return access_token, refresh_token
跨域认证
Session的跨域问题:
- 浏览器同源策略限制Cookie跨域发送
- 需要配置CORS或使用JSONP
- SSO(单点登录)需要中心化Session存储
JWT的跨域优势:
- Token放在Header中,不受Cookie同源限制
- 跨域请求只需带Authorization头
- 更适合前后端分离架构
# 前端跨域携带JWT
fetch('https://api.example.com/user', {
headers: {
'Authorization': `Bearer ${accessToken}`,
'Content-Type': 'application/json'
}
})
常见误区
误区1:JWT比Session更安全
错误。安全性取决于具体实现,不是认证方案本身。
- Session如果Redis被黑,所有会话泄露
- JWT如果密钥泄露,所有Token可被伪造
- 两者都可能被XSS攻击窃取
正确的做法:根据业务需求选方案,然后做好安全防护。
误区2:JWT Payload是加密的
错误。JWT的Header和Payload只是Base64编码,任何人都能解码看到内容:
import base64
import json
token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
# 任何人都能解码
header = json.loads(base64.urlsafe_b64decode(token.split('.')[0] + "=="))
payload = json.loads(base64.urlsafe_b64decode(token.split('.')[1] + "=="))
print(header) # {"alg":"HS256","typ":"JWT"}
print(payload) # {"sub":"1234567890","name":"John Doe"}
⚠️
不要在JWT Payload中存储敏感信息,如密码、身份证号、银行卡号。如果必须存储,先加密。
误区3:Token放URL参数里没问题
错误。URL参数会被记录在:
- 浏览器历史记录
- 服务器日志
- Referer头(跳转来源)
- 代理服务器日志
正确做法:使用Authorization Header或Cookie传递Token。
误区4:Token永远不设置过期
严重错误。没有过期时间的Token,一旦泄露,攻击者就有永久访问权限。
合理过期时间建议:
误区5:Session一定比JWT慢
不准确。Session多一次Redis查询,但通常只需要0.1~1ms。JWT的签名验证是纯CPU计算,现代CPU每秒能验证数十万次。
真正的性能差距:只有在极高的QPS(10万+)下,JWT的"无网络开销"优势才明显。普通应用中,两者的性能差距可以忽略不计。
记忆技巧
口诀
Session:台账模式,数据在我手,改了立即有
JWT:票根模式,数据随身走,发了收不回
短期Token保安全,长期Refresh来续命
需要撤销选Session,需要扩展选JWT
选型决策树
flowchart TD
A[需要选认证方案] --> B{需要立即撤销权限?}
B -->|是| C[需要精细化控制会话?]
B -->|否| D{微服务/开放API?}
C -->|是| E[选Session]
C -->|否| F[短期JWT<br/>+RefreshToken]
D -->|是| G[选JWT]
D -->|否| H{团队规模小?]
H -->|是| I[选Session<br/>简单易维护]
H -->|否| J[评估后决定<br/>考虑混合方案]
E --> K[配合Redis存储]
F --> L[短期Access<br/>长期Refresh]
G --> M[无状态扩展<br/>RS256签名]
对比速查表
实战检验
检验1:设计一个登录系统
需求:
- 用户名密码登录
- 限制同一账号最多3个设备同时在线
- 支持主动登出所有设备
推荐方案:混合方案
# 登录逻辑
def login(username, password):
user = verify_credentials(username, password)
# 检查当前设备数
current_devices = redis.keys(f"refresh:{user.id}:*")
if len(current_devices) >= 3:
# 删除最早的设备
oldest = get_oldest_device(current_devices)
redis.delete(oldest)
# 生成Token
access_token = create_access_token(user.id, expires_in=900) # 15分钟
refresh_token = create_refresh_token(user.id, device_id, expires_in=86400) # 1天
# 存储RefreshToken
redis.setex(f"refresh:{user.id}:{device_id}", 86400, json.dumps({
"created_at": time.time()
}))
return {
"access_token": access_token,
"refresh_token": refresh_token,
"expires_in": 900
}
# 登出所有设备
def logout_all(user_id):
devices = redis.keys(f"refresh:{user_id}:*")
for device in devices:
redis.delete(device)
return {"message": "已登出所有设备"}
检验2:排查"用户登录后立即掉线"
排查思路:
1. 检查Token是否正确返回
- 浏览器开发者工具 → Network → 找登录请求 → 看响应头/响应体
2. 检查Token是否被正确存储
- Chrome DevTools → Application → Cookies/LocalStorage
3. 检查请求是否带Token
- Network → 请求头是否有 Authorization
4. 检查Token是否过期
- jwt.io 解码Token → 看 exp 字段
5. 检查服务端验证逻辑
- 签名算法是否正确
- 密钥是否一致
- 过期时间是否合理
延伸阅读