Access Token 与 Refresh Token 的认证架构设计
在常见的 Web / SPA 认证系统里,Access Token 和 Refresh Token 经常被简单理解成“一个短 Token + 一个长 Token”。这种理解能解释基本流程,但到了多端登录、退出、Token 刷新、并发请求、页面重载和安全撤销时,很快就会变得混乱。
更稳定的心智模型是把认证拆成三层:
- Auth Session:描述“一次登录关系”,控制这次登录能持续多久,以及是否已经被撤销。
- Refresh Token(RT):用于证明客户端仍然拥有延续这次登录的资格。
- Access Token(AT):短期 API Credential,用来访问业务 API。
它们的关系不是一一绑定,而是:
User
│
└── Auth Session S1
│
├── Refresh Token
│
└── Access Token AT1、AT2、AT3 ...一、为什么还需要 Auth Session
Access Token 和 Refresh Token 已经存在时,Session 看起来似乎是多余的一层。实际上,Session 的价值在于把“登录”从一个瞬时动作建模成一个有 ID、有生命周期、有状态的实体。
例如一个用户分别在 PC 和手机登录:
User 1001
├── Session S001:Windows Chrome
└── Session S002:iPhone这样才能自然支持:
- 退出当前设备;
- 踢掉某个设备;
- 修改密码后让所有设备重新登录;
- 记录某次登录的创建时间、设备、IP 和认证等级;
- 控制一次登录的绝对最长生命周期。
一个最小的 Auth Session 可以是:
AuthSession
├── id
├── userId
├── refreshTokenHash
├── createdAt
├── expiresAt
└── status实际项目还可以增加:
lastRefreshAt
deviceId
userAgent
loginIp
mfaVerifiedAt
idleExpiresAt
absoluteExpiresAt关键不是字段多少,而是这些字段都应该描述“这一次登录”,而不是把 Session 当成任意业务状态的全局存储袋。
二、Access Token 的职责
Access Token 最适合做成短生命周期 JWT,由客户端通过 Bearer Authorization 发送给业务 API:
Authorization: Bearer <access-token>JWT 中通常只携带 API 鉴权需要的最小信息,例如:
{
"iss": "auth.example.com",
"sub": "user-1001",
"aud": "trading-api",
"sid": "session-S001",
"scope": "order:read order:create",
"iat": 1787648400,
"exp": 1787650200
}业务 API 只需要验证:
- 签名;
iss;aud;exp;- scope / permission。
正常情况下不需要访问 Redis,也不需要读取 Refresh Token,因此 Access Token 可以保持无状态验证。
Access Token 的“刷新”本质上是重新签发
JWT 一旦签发后不能延长自身寿命,因此所谓 Access Token refresh 实际上是:
AT1 过期或接近过期
↓
使用 RT 调用 /auth/refresh
↓
签发新的 AT2而不是:
AT1.exp += 30min一个常见的 TTL 是:
Access Token TTL = 15 ~ 30 min这个时间同时也是旧授权状态最多还能继续存在多久的安全窗口。
三、Refresh Token 的职责
Refresh Token 不用于访问普通业务 API,只用于 Auth Service:
Client
│
├── Bearer AT ─────────→ Business API
│
└── HttpOnly RT ───────→ Auth Service /auth/refreshWeb 场景推荐:
Refresh Token
├── opaque random token
├── HttpOnly Cookie
├── Secure
├── SameSite
└── 服务端只保存 token hash不建议把 RT 放到 localStorage,因为 XSS 一旦拿到长期 Refresh Token,攻击者就能持续获取新的 Access Token。
RT 使用 HttpOnly Cookie 后,前端 JavaScript 不需要也不应该读取它;浏览器在调用 /auth/refresh 时自动携带 Cookie。
四、RT 过期和 Refresh Token Rotation 是两件不同的事
这一点很容易混淆。
RT 过期
例如:
RT TTL = 7 days表示当前 Refresh Token 最多能使用到某个时间。到期后:
/auth/refresh
↓
RT expired
↓
必须重新登录Refresh Token Rotation
Rotation 是额外的安全策略:
RT1 被成功使用一次
↓
RT1 废弃
↓
签发 RT2于是一次 refresh 可以变成:
AT1 + RT1
↓ /auth/refresh
AT2 + RT2这里 RT1 被更换不是因为它快过期,而是因为 Rotation 策略规定“RT 使用一次即作废”。
因此,Access Token 更新和 Refresh Token Rotation 是两个独立机制。
系统完全可以采用固定 RT:
RT1 → AT1
RT1 → AT2
RT1 → AT3也可以采用 Rotating RT:
RT1 → AT2 + RT2
RT2 → AT3 + RT3
RT3 → AT4 + RT4Rotation 的主要价值是降低 RT 被窃取后的长期重放风险,并支持 reuse detection;代价则是更复杂的并发、重试和多 Tab 状态处理。
对于普通业务系统,第一版完全可以使用固定 RT;如果安全等级较高,再增加 Rotation。
五、推荐的生命周期模型
一个比较清晰的三层时间模型是:
Access Token TTL = 15 ~ 30 min
Refresh Token idle TTL = 7 days
Session absolute TTL = 30 days三者分别控制:
| 层级 | 控制的问题 |
|---|---|
| Access Token | 单个 API Credential 最多使用多久 |
| Refresh Token | 客户端多久没有续期后需要重新登录 |
| Auth Session | 无论多活跃,这次登录最长允许持续多久 |
如果使用 Session absolute TTL,那么即使 Refresh Token 不断刷新,也不能突破 Session 的绝对过期时间。
例如:
8 月 25 日登录
↓
Session S1 absoluteExpiresAt = 9 月 24 日即使 9 月 23 日仍然正常刷新 AT,到了 9 月 24 日 Session 仍然必须结束并重新登录。
六、Access Token 应该放在哪里
对于 SPA,一个安全而简单的组合是:
AT → JavaScript memory
RT → HttpOnly Cookie如果 AT 只存在内存中:
- 页面刷新:AT 丢失;
- 关闭页面:AT 丢失;
- 新开 Tab:没有 AT;
- RT Cookie 仍然存在。
这不是缺陷,而是预期行为。
应用每次完整启动时执行一次认证恢复:
Application Bootstrap
↓
memory 中没有 AT
↓
POST /auth/refresh
↓
浏览器自动携带 RT Cookie
↓
Auth Service 验证 Session + RT
↓
返回新的 AT
↓
保存到 memory
↓
进入 AUTHENTICATED 状态客户端状态可以非常简单:
UNKNOWN
│
│ /auth/refresh
│
├── success → AUTHENTICATED
└── 401 → UNAUTHENTICATED从客户端语义上,这一步甚至可以叫 restoreSession(),因为应用启动时根本没有旧 AT,需要恢复的是认证上下文。
七、正常刷新不要依赖 401
最差的设计是把:
请求 → 401 → refresh → 重发当成正常路径。
如果同时有多个业务请求,就会变成:
Request A → 401
Request B → 401
Request C → 401
↓
多个 refresh
↓
多个业务请求重发更好的方式是:请求发送前主动判断 AT 是否接近过期。
客户端保存:
accessToken
accessTokenExpiresAt例如:
AT TTL = 30min
提前刷新窗口 = 2min请求前:
AT 剩余 > 2min
↓
直接请求
AT 剩余 <= 2min
↓
先 /auth/refresh
↓
得到 AT2
↓
再发送业务请求客户端可以从 JWT 的 exp 读取过期时间,也可以直接使用 Auth Service 返回的 expiresIn 计算 accessTokenExpiresAt。
八、并发刷新必须使用 Single Flight
即使采用主动刷新,也可能出现多个请求同时发现 AT 快过期:
Request A ─┐
Request B ─┼─ 都发现 AT 快过期
Request C ─┘客户端不能分别调用三个 /auth/refresh,而应该只有一个共享的 refreshPromise:
A:创建 refreshPromise
B:await refreshPromise
C:await refreshPromise刷新完成后:
AT2
↓
A / B / C 全部继续这种模式就是 Single Flight,也可以理解为 Token Refresh Queue。
它的职责应该放在统一的 TokenManager 或 HTTP Client 基础设施层,而不是让每个业务模块单独处理。
九、401 只作为 fallback
即使客户端主动判断 AT 过期时间,仍然可能因为:
- 客户端和服务端时钟偏差;
- 网络延迟;
- 请求在服务端处理时刚好跨过
exp;
而收到 401。
因此仍然需要一条异常兜底路径:
业务请求
↓
401 ACCESS_TOKEN_EXPIRED
↓
Single Flight /auth/refresh
↓
成功
↓
原请求 retry once必须注意两点:
第一,不是所有 401 都应该触发 refresh。服务端应该返回明确错误码,例如:
ACCESS_TOKEN_EXPIRED
TOKEN_INVALID
SESSION_REVOKED
ACCOUNT_DISABLED只有 ACCESS_TOKEN_EXPIRED 才尝试 refresh。
第二,原业务请求最多重试一次,防止:
401 → refresh → retry → 401 → refresh → ...形成死循环。
如果 /auth/refresh 自身失败,例如 RT 或 Session 已过期,则直接进入重新登录流程。
十、不要让业务 API 顺手刷新 RT
一种看似省事的方案是:业务请求返回时发现 Token 快过期,就顺便返回新的 AT,并通过 Set-Cookie 写入新的 RT。
这种方案的问题是并发响应顺序不可控。
例如三个业务请求同时携带 RT1:
A: RT1 → RT2
B: RT1 → RT3
C: RT1 → RT4如果响应按 C、A、B 的顺序到达,浏览器最后保存哪个 Cookie 与服务端当前认为哪个 RT 有效就可能不一致。
因此推荐严格划分职责:
Business API
├── 只验证 Access Token
└── 不读取、不签发、不轮换 Refresh Token
Auth Service
├── /login
├── /refresh
├── /logout
└── 唯一负责 Refresh Token 和 Session这样 Access Token 的无状态验证优势也能保留下来。
十一、推荐的客户端职责划分
客户端可以拆成三个部分。
AuthStore
只保存短期状态:
accessToken
accessTokenExpiresAtRT 不进入 JavaScript 状态,由 HttpOnly Cookie 管理。
TokenManager
负责:
getValidAccessToken()
refresh()
restoreSession()
refreshPromiseHttpClient
请求前:
getValidAccessToken()
↓
Authorization: Bearer AT响应后:
ACCESS_TOKEN_EXPIRED
↓
refresh()
↓
retry once这样 OrderService、UserService、PositionService 等业务层都不需要知道 RT、Rotation 和 Session 的存在。
十二、最终推荐架构
对于一个普通现代 Web / SPA 系统,可以先采用下面的基线:
Access Token
├── JWT
├── Bearer Authorization
├── TTL 15 ~ 30 min
├── JS memory
└── Business API 本地验证
Refresh Token
├── opaque random token
├── HttpOnly + Secure Cookie
├── idle TTL 7 days
├── 服务端保存 hash
└── 仅 Auth Service 使用
Auth Session
├── sid
├── userId
├── refreshTokenHash
├── createdAt
├── idleExpiresAt
├── absoluteExpiresAt
└── status刷新流程:
其中 RT Rotation 是可选安全增强:
- 普通系统可以先使用固定 RT,降低并发与多 Tab 复杂度;
- 高安全系统可以启用 Rotation,并额外实现原子更新、重放检测和并发容错。
客户端的正常路径应该始终是:
应用启动
↓
restoreSession()
↓
获得内存 AT
↓
业务请求前检查 AT 是否快过期
↓
必要时 Single Flight refresh
↓
正常发送业务请求而:
401 → refresh → retry once只是一条异常兜底路径。
总结
Access Token、Refresh Token 和 Session 的职责可以压缩成三句话:
Session 管一次登录。
Refresh Token 管这次登录还能不能继续。
Access Token 管当前这个 API 请求有没有短期访问资格。
在这个模型下,AT 可以保持短期、无状态、容易丢弃;RT 作为长期凭证只进入 Auth Service;Session 负责撤销、多端登录和最长登录周期。客户端通过内存 AT、HttpOnly RT、主动刷新、Single Flight 和 401 fallback,将刷新逻辑统一收敛到认证基础设施层,而不是扩散到每个业务接口。