双 Token 认证原理与实现步骤
1. 什么是双 Token
双 Token 指同时使用两种令牌:
| Token | 用途 | 常见有效期 |
|---|---|---|
| Access Token | 调用普通业务接口 | 较短,例如 15 分钟 |
| Refresh Token | Access Token 过期后换取新 Token | 较长,例如 7 天 |
可以简单理解为:
Access Token = 临时通行证
Refresh Token = 补办临时通行证的凭证
用户调用课程、练习、AI 对话等普通业务接口时,只携带 Access Token。
Refresh Token 不参与普通业务请求,只发送给专门的刷新接口。
2. 为什么需要双 Token
如果项目只使用一个 JWT,就会面临安全性和用户体验之间的矛盾。
Token 有效期较长
优点:用户不需要频繁登录
缺点:Token 泄露后,攻击者可以使用很长时间
Token 有效期较短
优点:Token 泄露后的可利用时间较短
缺点:用户可能每隔十几分钟就要重新登录
双 Token 的解决方式
Access Token 生命周期短,降低泄露风险
Refresh Token 生命周期长,维持用户登录状态
3. 完整工作流程
3.1 用户登录
用户名和密码
→ 后端验证用户
→ 生成 Access Token
→ 生成 Refresh Token
推荐的返回方式:
Access Token
→ 返回给前端
→ 保存在 Zustand 等内存状态中
Refresh Token
→ 通过 Set-Cookie 写入浏览器
→ 使用 HttpOnly Cookie 保存
HttpOnly Cookie 不能被前端 JavaScript 读取,可以降低 XSS 攻击直接窃取 Refresh Token 的风险。
3.2 调用普通接口
例如请求小节内容:
GET /api/v1/chapter/lesson/abc12
Authorization: Bearer ACCESS_TOKEN
后端的认证中间件只验证 Access Token。
验证成功:
返回小节内容
Access Token 已过期:
HTTP/1.1 401 Unauthorized
Content-Type: application/json
{
"code": "TOKEN_EXPIRED",
"message": "Access Token 已过期"
}
3.3 自动刷新
前端发现接口返回:
HTTP 401
+ TOKEN_EXPIRED
前端调用刷新接口:
POST /api/v1/auth/refresh
Cookie: refreshToken=...
因为 Refresh Token 保存在 Cookie 中,所以浏览器可以自动携带,不需要 JavaScript 读取它。
后端验证 Refresh Token:
Refresh Token 有效
→ 生成新的 Access Token
→ 返回给前端
→ 前端重新发送刚才失败的请求
这个过程通常由请求拦截器自动完成,用户不会明显感觉到。
3.4 Refresh Token 也已过期
如果刷新接口同样返回 401:
清空前端登录状态
→ 删除本地 Access Token
→ 跳转登录页面
→ 用户重新登录
这才是真正的登录失效。
4. 是否需要更换 Refresh Token
4.1 简单方案
每次刷新时,只生成新的 Access Token:
旧 Refresh Token
→ 新 Access Token
优点:
- 实现简单。
- 容易理解和调试。
缺点:
- Refresh Token 可以被重复使用。
- Refresh Token 泄露后,攻击者可以持续刷新,直到它自然过期。
4.2 推荐方案:Refresh Token Rotation
每次刷新时,同时更换 Access Token 和 Refresh Token:
旧 Refresh Token
→ 新 Access Token
→ 新 Refresh Token
→ 旧 Refresh Token 立即作废
这个过程叫作 Refresh Token Rotation,即刷新令牌轮换。
如果已经作废的旧 Refresh Token 再次出现,说明它可能已经泄露。后端可以撤销整个登录会话,要求用户重新登录。
5. JWT 为什么还需要 Redis 或数据库
JWT 经常被称为“无状态认证”,但不代表认证系统完全不需要服务端状态。
Access Token 可以采用无状态验证:
后端只验证 JWT 签名和过期时间
Refresh Token 最好建立服务端会话,并在 Redis 或数据库中保存:
会话 ID
用户 ID
Refresh Token 哈希
Token 唯一 ID
过期时间
设备信息
是否已撤销
创建时间
最近刷新时间
不要直接保存 Refresh Token 明文,应该保存它的哈希值,思路类似于密码存储。
CodeStory 已经使用 Redis,后续可以设计类似的键:
refresh_session:{sessionId}
示例数据:
{
"userId": "user-123",
"refreshTokenHash": "...",
"tokenId": "token-456",
"expiresAt": "2026-06-20T10:00:00.000Z",
"revoked": false
}
6. 两种 Token 应该包含什么
6.1 Access Token
{
"sub": "用户 ID",
"role": 0,
"sessionId": "登录会话 ID",
"type": "access",
"iat": 1000000000,
"exp": 1000000900
}
建议包含:
sub:用户 ID。role:用户角色。sessionId:所属登录会话。type:固定为access。iat:签发时间。exp:过期时间。
6.2 Refresh Token
{
"sub": "用户 ID",
"sessionId": "登录会话 ID",
"tokenId": "Refresh Token 唯一 ID",
"type": "refresh",
"iat": 1000000000,
"exp": 1000604800
}
建议包含:
sub:用户 ID。sessionId:所属登录会话。tokenId:用于轮换和撤销的唯一 ID。type:固定为refresh。iat:签发时间。exp:过期时间。
必须使用 type 区分两种 Token,防止有人使用 Refresh Token 调用普通业务接口。
7. 推荐的前端存储方案
Access Token
保存在 Zustand 等内存状态中
特点:
- JavaScript 可以读取。
- 页面刷新后会消失。
- 页面刷新后,可以通过 Refresh Token 重新获取。
Refresh Token
保存在 Secure + HttpOnly + SameSite Cookie 中
特点:
- JavaScript 无法读取 HttpOnly Cookie。
- 浏览器可以在刷新请求中自动携带。
- 生产环境应通过 HTTPS 传输。
不推荐的方案
Access Token 和 Refresh Token 全部存入 localStorage
如果页面发生 XSS,恶意脚本就可能同时读取两个 Token。
8. Cookie 需要注意的配置
推荐配置:
HttpOnly = true
Secure = 生产环境为 true
SameSite = Lax 或 Strict
Path = /api/v1/auth
各配置的作用:
| 配置 | 作用 |
|---|---|
| HttpOnly | 禁止 JavaScript 读取 Cookie |
| Secure | 只允许通过 HTTPS 发送 |
| SameSite | 降低跨站请求伪造风险 |
| Path | 限制 Cookie 只发送给认证相关路径 |
| Max-Age / Expires | 设置 Refresh Token 有效期 |
如果前后端跨域,还需要处理:
- 后端 CORS 的
credentials: true。 - 前端请求的
credentials: "include"。 - Cookie 的
SameSite和Secure。 - 允许的前端域名,不能使用任意
*。 - 必要的 CSRF 防护。
9. CodeStory 的推荐实现顺序
真正开发时,建议按照以下顺序实施。
第一步:定义认证规则
先确定:
- Access Token 有效期,例如 15 分钟。
- Refresh Token 有效期,例如 7 天。
- Access Token 的存储位置。
- Refresh Token 的 Cookie 配置。
- 是否使用 Refresh Token Rotation。
- 一个用户是否允许多个设备同时登录。
第二步:拆分 Token 工具
分别实现:
生成 Access Token
验证 Access Token
生成 Refresh Token
验证 Refresh Token
不要使用同一个函数和完全相同的配置处理两种 Token。
第三步:建立刷新会话
使用 Redis 或数据库保存:
sessionId
userId
refreshTokenHash
tokenId
expiresAt
revoked
第四步:修改登录接口
登录成功后:
生成 Access Token
生成 Refresh Token
创建刷新会话
通过 JSON 返回 Access Token
通过 HttpOnly Cookie 返回 Refresh Token
第五步:修改认证中间件
普通接口只允许使用 Access Token:
验证签名
验证过期时间
验证 type === "access"
读取用户 ID 和角色
第六步:增加刷新接口
例如:
POST /api/v1/auth/refresh
接口负责:
读取 Refresh Cookie
验证 Refresh Token
查询服务端刷新会话
比较 Refresh Token 哈希
执行 Token Rotation
返回新的 Access Token
设置新的 Refresh Cookie
第七步:修改前端请求拦截器
业务接口返回 Access Token 过期时:
调用刷新接口
→ 获取新 Access Token
→ 更新 Zustand 状态
→ 重新发送原请求
第八步:处理并发刷新
多个请求同时过期时,只允许发送一次刷新请求。
其他请求等待刷新结果,然后统一重试。
第九步:修改退出登录
退出登录时:
撤销 Redis 刷新会话
→ 清除 Refresh Cookie
→ 清空前端 Access Token
→ 清空用户信息
第十步:补充安全测试
至少测试:
- Access Token 正常使用。
- Access Token 已过期。
- Refresh Token 正常刷新。
- Refresh Token 已过期。
- Refresh Token 被伪造。
- Refresh Token 被重复使用。
- 使用 Refresh Token 调用普通接口。
- 用户退出后再次刷新。
- 多个请求同时触发刷新。
- Redis 会话被撤销后再次刷新。
10. 前端最容易踩的坑:并发刷新
假设页面同时发出三个请求:
获取用户信息
获取小节内容
获取课程进度
三个请求同时发现 Access Token 已过期。
错误做法
请求 A → 刷新一次
请求 B → 刷新一次
请求 C → 刷新一次
如果使用 Refresh Token Rotation:
请求 A 使用旧 Refresh Token 刷新成功
→ 旧 Refresh Token 立即作废
请求 B 再使用旧 Refresh Token
→ 刷新失败
请求 C 再使用旧 Refresh Token
→ 刷新失败
最终可能导致用户被错误地退出登录。
正确做法
第一个 401
→ 创建唯一的刷新 Promise
其他 401
→ 等待这个 Promise
刷新成功
→ 更新 Access Token
→ 所有失败请求使用新 Token 重新发送
伪代码思路:
let refreshPromise: Promise<string> | null = null;
async function getNewAccessToken() {
if (!refreshPromise) {
refreshPromise = refreshAccessToken().finally(() => {
refreshPromise = null;
});
}
return refreshPromise;
}
这段代码现在不需要照着实现,重点理解:
同一时刻只能存在一个刷新请求。
11. 完整时序图
sequenceDiagram
participant U as 用户
participant F as Next.js 前端
participant B as Express 后端
participant R as Redis
U->>F: 登录
F->>B: 提交账号和密码
B->>R: 创建刷新会话
B-->>F: Access Token + Refresh Cookie
F->>B: 携带 Access Token 请求课程
B-->>F: 返回课程数据
F->>B: 携带已过期的 Access Token
B-->>F: 401 TOKEN_EXPIRED
F->>B: 请求刷新,自动携带 Refresh Cookie
B->>R: 验证刷新会话
B->>R: 轮换 Refresh Token
B-->>F: 新 Access Token + 新 Refresh Cookie
F->>B: 使用新 Access Token 重试原请求
B-->>F: 返回业务数据
12. 常见安全问题
12.1 不区分 Token 类型
错误:
Access Token 和 Refresh Token 使用相同验证逻辑
风险:
攻击者可能使用 Refresh Token 调用普通接口
解决:
Access Token 必须满足 type === "access"
Refresh Token 必须满足 type === "refresh"
12.2 Refresh Token 不可撤销
如果后端不保存刷新会话:
用户退出登录后
→ 已签发的 Refresh Token 仍可能继续刷新
解决:
使用 Redis 或数据库保存刷新会话
退出登录时撤销会话
12.3 在日志中打印 Token
不要记录:
Authorization 请求头
完整 Access Token
完整 Refresh Token
Cookie 原文
12.4 无限自动刷新
如果刷新接口本身返回 401,前端不能再次拦截并继续刷新,否则会形成死循环。
需要明确排除:
/api/v1/auth/login
/api/v1/auth/refresh
12.5 所有 401 都触发刷新
401 不一定代表 Access Token 过期,也可能是:
- Token 被伪造。
- Token 格式错误。
- 用户不存在。
- 登录会话被撤销。
前端最好只在后端返回明确的 TOKEN_EXPIRED 错误码时刷新。
13. 推荐错误码
| HTTP 状态码 | 业务错误码 | 含义 |
|---|---|---|
| 401 | ACCESS_TOKEN_MISSING | 未携带 Access Token |
| 401 | ACCESS_TOKEN_EXPIRED | Access Token 已过期,可以尝试刷新 |
| 401 | ACCESS_TOKEN_INVALID | Access Token 无效,不应刷新 |
| 401 | REFRESH_TOKEN_MISSING | 未携带 Refresh Token |
| 401 | REFRESH_TOKEN_EXPIRED | Refresh Token 已过期,需要重新登录 |
| 401 | REFRESH_TOKEN_INVALID | Refresh Token 无效 |
| 401 | SESSION_REVOKED | 登录会话已被撤销 |
| 403 | FORBIDDEN | 已登录但没有操作权限 |
只有:
ACCESS_TOKEN_EXPIRED
应该自动触发刷新。
14. 最需要记住的三个概念
短期 Access Token 负责访问
长期 Refresh Token 负责续期
服务端刷新会话负责撤销和轮换
完整流程可以压缩成:
登录
→ 获得 Access Token 和 Refresh Token
→ Access Token 调用业务接口
→ Access Token 过期
→ Refresh Token 换取新 Token
→ 自动重试原请求
→ Refresh Token 失效后重新登录
15. 学习检查清单
复习完成后,尝试回答以下问题:
- 为什么不能只使用一个长期 JWT?
- Access Token 和 Refresh Token 分别负责什么?
- 为什么 Refresh Token 更适合放在 HttpOnly Cookie?
- 为什么 Refresh Token 最好在 Redis 中保存会话?
- 什么是 Refresh Token Rotation?
- 为什么并发刷新会产生问题?
- 为什么只有
ACCESS_TOKEN_EXPIRED才应该自动刷新? - 为什么退出登录时必须撤销服务端刷新会话?
如果能够用自己的语言回答这些问题,就已经掌握了双 Token 的核心思路。