Skip to content

Access Token 与 Refresh Token 的认证架构设计 ​

在常见的 Web / SPA 认证系统里,Access Token 和 Refresh Token 经常被简单理解成“一个短 Token + 一个长 Token”。这种理解能解释基本流程,但到了多端登录、退出、Token 刷新、并发请求、页面重载和安全撤销时,很快就会变得混乱。

更稳定的心智模型是把认证拆成三层:

  • Auth Session:描述“一次登录关系”,控制这次登录能持续多久,以及是否已经被撤销。
  • Refresh Token(RT):用于证明客户端仍然拥有延续这次登录的资格。
  • Access Token(AT):短期 API Credential,用来访问业务 API。

它们的关系不是一一绑定,而是:

text
User
  │
  └── Auth Session S1
         │
         ├── Refresh Token
         │
         └── Access Token AT1、AT2、AT3 ...

一、为什么还需要 Auth Session ​

Access Token 和 Refresh Token 已经存在时,Session 看起来似乎是多余的一层。实际上,Session 的价值在于把“登录”从一个瞬时动作建模成一个有 ID、有生命周期、有状态的实体。

例如一个用户分别在 PC 和手机登录:

text
User 1001
├── Session S001:Windows Chrome
└── Session S002:iPhone

这样才能自然支持:

  • 退出当前设备;
  • 踢掉某个设备;
  • 修改密码后让所有设备重新登录;
  • 记录某次登录的创建时间、设备、IP 和认证等级;
  • 控制一次登录的绝对最长生命周期。

一个最小的 Auth Session 可以是:

text
AuthSession
├── id
├── userId
├── refreshTokenHash
├── createdAt
├── expiresAt
└── status

实际项目还可以增加:

text
lastRefreshAt
deviceId
userAgent
loginIp
mfaVerifiedAt
idleExpiresAt
absoluteExpiresAt

关键不是字段多少,而是这些字段都应该描述“这一次登录”,而不是把 Session 当成任意业务状态的全局存储袋。

二、Access Token 的职责 ​

Access Token 最适合做成短生命周期 JWT,由客户端通过 Bearer Authorization 发送给业务 API:

http
Authorization: Bearer <access-token>

JWT 中通常只携带 API 鉴权需要的最小信息,例如:

json
{
  "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 实际上是:

text
AT1 过期或接近过期
↓
使用 RT 调用 /auth/refresh
↓
签发新的 AT2

而不是:

text
AT1.exp += 30min

一个常见的 TTL 是:

text
Access Token TTL = 15 ~ 30 min

这个时间同时也是旧授权状态最多还能继续存在多久的安全窗口。

三、Refresh Token 的职责 ​

Refresh Token 不用于访问普通业务 API,只用于 Auth Service:

text
Client
  │
  ├── Bearer AT ─────────→ Business API
  │
  └── HttpOnly RT ───────→ Auth Service /auth/refresh

Web 场景推荐:

text
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 过期 ​

例如:

text
RT TTL = 7 days

表示当前 Refresh Token 最多能使用到某个时间。到期后:

text
/auth/refresh
↓
RT expired
↓
必须重新登录

Refresh Token Rotation ​

Rotation 是额外的安全策略:

text
RT1 被成功使用一次
↓
RT1 废弃
↓
签发 RT2

于是一次 refresh 可以变成:

text
AT1 + RT1
    ↓ /auth/refresh
AT2 + RT2

这里 RT1 被更换不是因为它快过期,而是因为 Rotation 策略规定“RT 使用一次即作废”。

因此,Access Token 更新和 Refresh Token Rotation 是两个独立机制。

系统完全可以采用固定 RT:

text
RT1 → AT1
RT1 → AT2
RT1 → AT3

也可以采用 Rotating RT:

text
RT1 → AT2 + RT2
RT2 → AT3 + RT3
RT3 → AT4 + RT4

Rotation 的主要价值是降低 RT 被窃取后的长期重放风险,并支持 reuse detection;代价则是更复杂的并发、重试和多 Tab 状态处理。

对于普通业务系统,第一版完全可以使用固定 RT;如果安全等级较高,再增加 Rotation。

五、推荐的生命周期模型 ​

一个比较清晰的三层时间模型是:

text
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 的绝对过期时间。

例如:

text
8 月 25 日登录
↓
Session S1 absoluteExpiresAt = 9 月 24 日

即使 9 月 23 日仍然正常刷新 AT,到了 9 月 24 日 Session 仍然必须结束并重新登录。

六、Access Token 应该放在哪里 ​

对于 SPA,一个安全而简单的组合是:

text
AT → JavaScript memory
RT → HttpOnly Cookie

如果 AT 只存在内存中:

  • 页面刷新:AT 丢失;
  • 关闭页面:AT 丢失;
  • 新开 Tab:没有 AT;
  • RT Cookie 仍然存在。

这不是缺陷,而是预期行为。

应用每次完整启动时执行一次认证恢复:

text
Application Bootstrap
↓
memory 中没有 AT
↓
POST /auth/refresh
↓
浏览器自动携带 RT Cookie
↓
Auth Service 验证 Session + RT
↓
返回新的 AT
↓
保存到 memory
↓
进入 AUTHENTICATED 状态

客户端状态可以非常简单:

text
UNKNOWN
  │
  │ /auth/refresh
  │
  ├── success → AUTHENTICATED
  └── 401     → UNAUTHENTICATED

从客户端语义上,这一步甚至可以叫 restoreSession(),因为应用启动时根本没有旧 AT,需要恢复的是认证上下文。

七、正常刷新不要依赖 401 ​

最差的设计是把:

text
请求 → 401 → refresh → 重发

当成正常路径。

如果同时有多个业务请求,就会变成:

text
Request A → 401
Request B → 401
Request C → 401
↓
多个 refresh
↓
多个业务请求重发

更好的方式是:请求发送前主动判断 AT 是否接近过期。

客户端保存:

text
accessToken
accessTokenExpiresAt

例如:

text
AT TTL = 30min
提前刷新窗口 = 2min

请求前:

text
AT 剩余 > 2min
↓
直接请求

AT 剩余 <= 2min
↓
先 /auth/refresh
↓
得到 AT2
↓
再发送业务请求

客户端可以从 JWT 的 exp 读取过期时间,也可以直接使用 Auth Service 返回的 expiresIn 计算 accessTokenExpiresAt。

八、并发刷新必须使用 Single Flight ​

即使采用主动刷新,也可能出现多个请求同时发现 AT 快过期:

text
Request A ─┐
Request B ─┼─ 都发现 AT 快过期
Request C ─┘

客户端不能分别调用三个 /auth/refresh,而应该只有一个共享的 refreshPromise:

text
A:创建 refreshPromise
B:await refreshPromise
C:await refreshPromise

刷新完成后:

text
AT2
↓
A / B / C 全部继续

这种模式就是 Single Flight,也可以理解为 Token Refresh Queue。

它的职责应该放在统一的 TokenManager 或 HTTP Client 基础设施层,而不是让每个业务模块单独处理。

九、401 只作为 fallback ​

即使客户端主动判断 AT 过期时间,仍然可能因为:

  • 客户端和服务端时钟偏差;
  • 网络延迟;
  • 请求在服务端处理时刚好跨过 exp;

而收到 401。

因此仍然需要一条异常兜底路径:

text
业务请求
↓
401 ACCESS_TOKEN_EXPIRED
↓
Single Flight /auth/refresh
↓
成功
↓
原请求 retry once

必须注意两点:

第一,不是所有 401 都应该触发 refresh。服务端应该返回明确错误码,例如:

text
ACCESS_TOKEN_EXPIRED
TOKEN_INVALID
SESSION_REVOKED
ACCOUNT_DISABLED

只有 ACCESS_TOKEN_EXPIRED 才尝试 refresh。

第二,原业务请求最多重试一次,防止:

text
401 → refresh → retry → 401 → refresh → ...

形成死循环。

如果 /auth/refresh 自身失败,例如 RT 或 Session 已过期,则直接进入重新登录流程。

十、不要让业务 API 顺手刷新 RT ​

一种看似省事的方案是:业务请求返回时发现 Token 快过期,就顺便返回新的 AT,并通过 Set-Cookie 写入新的 RT。

这种方案的问题是并发响应顺序不可控。

例如三个业务请求同时携带 RT1:

text
A: RT1 → RT2
B: RT1 → RT3
C: RT1 → RT4

如果响应按 C、A、B 的顺序到达,浏览器最后保存哪个 Cookie 与服务端当前认为哪个 RT 有效就可能不一致。

因此推荐严格划分职责:

text
Business API
├── 只验证 Access Token
└── 不读取、不签发、不轮换 Refresh Token

Auth Service
├── /login
├── /refresh
├── /logout
└── 唯一负责 Refresh Token 和 Session

这样 Access Token 的无状态验证优势也能保留下来。

十一、推荐的客户端职责划分 ​

客户端可以拆成三个部分。

AuthStore ​

只保存短期状态:

text
accessToken
accessTokenExpiresAt

RT 不进入 JavaScript 状态,由 HttpOnly Cookie 管理。

TokenManager ​

负责:

text
getValidAccessToken()
refresh()
restoreSession()
refreshPromise

HttpClient ​

请求前:

text
getValidAccessToken()
↓
Authorization: Bearer AT

响应后:

text
ACCESS_TOKEN_EXPIRED
↓
refresh()
↓
retry once

这样 OrderService、UserService、PositionService 等业务层都不需要知道 RT、Rotation 和 Session 的存在。

十二、最终推荐架构 ​

对于一个普通现代 Web / SPA 系统,可以先采用下面的基线:

text
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,并额外实现原子更新、重放检测和并发容错。

客户端的正常路径应该始终是:

text
应用启动
↓
restoreSession()
↓
获得内存 AT
↓
业务请求前检查 AT 是否快过期
↓
必要时 Single Flight refresh
↓
正常发送业务请求

而:

text
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,将刷新逻辑统一收敛到认证基础设施层,而不是扩散到每个业务接口。

Last updated:

基于 VitePress 构建 · 工程、交易与系统研究日志