1. 引言

在基于 JWT(JSON Web Token)的鉴权体系中,AccessToken 通常设置为短有效期(15分钟~1小时),以降低令牌泄露造成的安全风险。但这也带来一个实际问题:客户端在 AccessToken 过期后,每次都需要用户重新登录获取新令牌,体验较差。本文说明如何通过双 Token 模型——即 AccessToken 与 RefreshToken 的分工协作,来实现无感续期。

阅读本文后,你将理解 RefreshToken 的核心作用与使用边界,掌握在前后端分离场景中配置双 Token 刷新机制的方法,知晓 RefreshToken 的安全存储方案,以及处理过期、并发刷新、轮换策略等常见问题的工程实践。

2. 为什么需要 RefreshToken——双 Token 模型的分工与设计初衷

2.1 单 Token 模式的局限

如果只使用一个 AccessToken 进行鉴权,通常有两种选择:

  • 短有效期:Token 15 分钟过期,用户每 15 分钟就需要重新登录,体验极差。
  • 长有效期:Token 设置 7 天或更长,一旦泄露,攻击者可在长时间内获得认证权限,安全风险大。

单 Token 模式在安全与体验之间难以平衡。频繁重新登录影响用户留存,而过长的有效期又难以在运维中控制风险。

2.2 双 Token 模型的分工

双 Token 模型引入两个角色:

  • AccessToken(访问令牌):短期有效,通常 15 分钟~1 小时。每次 API 请求都携带,用于服务端鉴权。泄露后影响范围小,因为有效期短。
  • RefreshToken(刷新令牌):长期有效,通常 7~30 天。仅在特定刷新接口使用,用于获取新的 AccessToken。泄露后攻击者可长期刷新令牌,因此对存储和传输安全性要求更高。

这种分工的核心思路是:将“每次请求都验证”的短期凭证与“仅在刷新时调用”的长期凭证分离,在保证安全的前提下实现无感续期。

2.3 RefreshToken 的使用边界

需要明确的是,RefreshToken 不能作为业务鉴权凭证。它只应在如下场景使用:

  • 客户端通过 /auth/refresh 接口向服务端发送 RefreshToken。
  • 服务端校验 RefreshToken 有效性,签发新的 AccessToken(和可选的新的 RefreshToken)。
  • 客户端用新的 AccessToken 发起后续请求。

任何业务接口都不应接受 RefreshToken 替代 AccessToken。

3. RefreshToken 的生成与数据结构设计

3.1 生成方式:随机字符串 vs JWT 格式

RefreshToken 的生成有两种主流方式:

方式一:加密随机字符串(推荐)
使用 crypto.randomBytes(32) 等强随机源生成 32 字节以上的随机字符串。这种方式不可预测,无法被逆向,安全性高。

1
2
3
# 伪码示例
import secrets
refresh_token = secrets.token_hex(32) # 64 字符十六进制字符串

方式二:JWT 格式
将 RefreshToken 也签发为 JWT,内部携带 userId、版本号、过期时间等信息。服务端可以无状态校验,但一旦 JWT 泄露,攻击者可以解析 payload 获取信息。实践中更推荐用随机字符串,由服务端维护状态,便于控制吊销和轮换。

3.2 服务端存储结构

无论哪种方式,服务端通常需要存储 RefreshToken 的关联信息。一个典型的数据库表或 Redis 哈希结构如下:

字段 类型 说明
user_id varchar 用户标识
token_hash varchar RefreshToken 的 SHA-256 哈希(或明文)
expires_at datetime 过期时间
is_used boolean 是否已被使用(轮换后标记)
created_at datetime 创建时间

如果使用 Redis,可设置 TTL 自动过期。关键点在于:每次刷新时需要查存储、验证哈希、标记使用、插入新记录。

3.3 设计要点

  • 长度 ≥ 32 字节:建议 64 字节以上,降低暴力碰撞概率。
  • 使用加密随机源:不要用 Math.random()time() 等可预测源。
  • 避免可预测性:禁止使用时间戳+自增序列的简单组合。

4. AccessToken 过期自动刷新机制——完整交互流程

4.1 前端拦截与刷新触发

在前后端分离架构中,前端通常通过 HTTP 拦截器(如 Axios 的 response.interceptors)统一处理 401 状态码:

  1. 客户端发起 API 请求,携带 AccessToken。

  2. 服务端校验发现 AccessToken 过期,返回 401 Unauthorized。

  3. 前端拦截器检测到 401,判断是否由 AccessToken 过期引起(而非权限不足)。

  4. 如果是过期,前端暂停所有待发请求,调用 /auth/refresh 接口,携带 RefreshToken。

  5. 服务端校验 RefreshToken 有效后,返回新的 AccessToken(以及新的 RefreshToken)。

  6. 前端用新 Token 重试原请求,并更新本地存储。

  7. 如果 RefreshToken 也过期或无效,服务端返回 401,前端清除本地 Token,跳转登录页。

4.2 服务端校验与颁发

服务端刷新逻辑的关键步骤:

  1. 接收 RefreshToken(放在请求体或 httpOnly Cookie 中)。

  2. 从存储中查找该 RefreshToken 的哈希,获取关联的 userId、过期时间、是否已使用。

  3. 检查是否过期(当前时间 > expires_at)。

  4. 检查是否已被使用(is_used = true),如果是,说明可能发生了重放,建议将关联的所有 RefreshToken 全部失效(攻击者可能已窃取)。

  5. 校验通过后,签发新的 AccessToken(短有效期)。

  6. 轮换:废弃旧的 RefreshToken(标记 is_used = true 或删除),生成并返回新的 RefreshToken。

  7. 如果需要,服务端可绑定指纹信息(如 User-Agent、IP 段)到 RefreshToken 记录,用于下次校验时比对。

4.3 流程图描述

1
2
3
4
5
6
7
8
9
10
11
12
13
客户端                             服务端
| |
|--- 请求(携带 AccessToken)-----> | 校验 AccessToken
|<---- 401(过期)--------------- |
| |
|--- POST /auth/refresh ---------> | 校验 RefreshToken
| (body: refreshToken) | 检查是否过期/被使用
| | 标记旧 RefreshToken 为已使用
| | 生成新 AccessToken + 新 RefreshToken
|<---- {accessToken, refreshToken} |
| |
|--- 重试原请求(携带新 AccessToken)-> |
|<---- 200 OK ------------------- |

5. 实战代码:Spring Security JWT 刷新 Token 配置

以下以 Spring Boot + Spring Security 为例,说明核心实现。假设项目中已集成 JWT 签发与校验。

5.1 存储层(Redis)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Component
public class RefreshTokenRepository {
@Autowired
private RedisTemplate<String, Object> redisTemplate;

private static final String PREFIX = "refresh_token:";

public void save(String tokenHash, String userId, long ttlSeconds) {
String key = PREFIX + tokenHash;
redisTemplate.opsForValue().set(key, userId, ttlSeconds, TimeUnit.SECONDS);
}

public String getUserIdByTokenHash(String tokenHash) {
String key = PREFIX + tokenHash;
Object value = redisTemplate.opsForValue().get(key);
return value != null ? value.toString() : null;
}

public void delete(String tokenHash) {
String key = PREFIX + tokenHash;
redisTemplate.delete(key);
}
}

Redis 的 TTL 天然解决过期问题,且读写速度快。

5.2 刷新服务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@Service
public class RefreshTokenService {
@Autowired
private RefreshTokenRepository repository;

@Value("${jwt.refresh-token-expiration:604800}") // 7天
private long refreshTokenExpiration;

public String generateRefreshToken() {
byte[] bytes = new byte[64];
SecureRandom.getInstanceStrong().nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}

public Map<String, String> refresh(String oldRefreshToken) {
// 1. 计算哈希,查找 Redis
String hash = SHA256.hash(oldRefreshToken);
String userId = repository.getUserIdByTokenHash(hash);
if (userId == null) {
throw new InvalidTokenException("RefreshToken 无效或已过期");
}

// 2. 删除旧 Token
repository.delete(hash);

// 3. 生成新 AccessToken 和新 RefreshToken
String newAccessToken = JwtUtils.createAccessToken(userId);
String newRefreshToken = generateRefreshToken();
String newHash = SHA256.hash(newRefreshToken);
repository.save(newHash, userId, refreshTokenExpiration);

return Map.of(
"accessToken", newAccessToken,
"refreshToken", newRefreshToken
);
}
}

注意:此处对 RefreshToken 存储的是 SHA-256 哈希,避免明文泄露。deletesave 实现轮换。

5.3 安全配置

SecurityConfig 中,将 /auth/refresh 路径加入白名单,无需 AccessToken 校验:

1
2
3
4
http
.authorizeRequests()
.antMatchers("/auth/refresh").permitAll()
.anyRequest().authenticated()

同时应配置 CSRF 防护,如果 RefreshToken 通过 POST 请求体发送,需要确保跨站请求伪造被限制。

6. 实战代码:Node.js(Express + jsonwebtoken)刷新接口实现

6.1 签发与存储

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const redis = require('redis');
const client = redis.createClient();

// 生成 RefreshToken(随机字符串)
function generateRefreshToken() {
return crypto.randomBytes(64).toString('hex'); // 128 字符十六进制字符串
}

// 刷新路由
app.post('/auth/refresh', async (req, res) => {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({ error: '缺少 refreshToken' });
}

// 在 Redis 中查找
const hash = crypto.createHash('sha256').update(refreshToken).digest('hex');
const userId = await client.get(`refresh:${hash}`);

if (!userId) {
return res.status(401).json({ error: 'RefreshToken 无效或已过期' });
}

// 删除旧 Token(轮换)
await client.del(`refresh:${hash}`);

// 签发新 Token
const newAccessToken = jwt.sign({ userId }, ACCESS_SECRET, { expiresIn: '15m' });
const newRefreshToken = generateRefreshToken();
const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
await client.setex(`refresh:${newHash}`, 7 * 24 * 60 * 60, userId); // 7天

res.json({
accessToken: newAccessToken,
refreshToken: newRefreshToken
});
});

6.2 中间件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
function authMiddleware(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: '缺少 AccessToken' });
}

const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, ACCESS_SECRET);
req.userId = decoded.userId;
next();
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'AccessToken 过期', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'AccessToken 无效' });
}
}

前端拦截器根据 code: 'TOKEN_EXPIRED' 触发刷新流程。

7. RefreshToken 存储安全方案(前端 + 后端)

7.1 前端存储方案对比

存储方式 风险 适用场景
localStorage 易受 XSS 攻击,攻击者可读取 不推荐存储 RefreshToken
sessionStorage 同样受 XSS 影响,但浏览器关闭即清除 可配合定时刷新使用,但仍有风险
httpOnly + Secure + SameSite=Strict Cookie 无法通过 JavaScript 读取,防 XSS 效果好 推荐,尤其适合 API 与前端同源
内存变量(闭包)+ 定时刷新 无持久存储,会话结束后丢失 高安全需求场景,但用户需频繁登录

建议:如果前后端同域,使用 httpOnly Cookie 存储 RefreshToken,前端无需关心其存取。如果跨域,可使用 SameSite=None; Secure 的 Cookie(需 HTTPS),或使用框架提供的封装方案。

7.2 后端存储安全

  • Redis 存储哈希:不存明文,即使 Redis 被入侵也无法直接使用 RefreshToken。
  • 定期清理过期记录:Redis TTL 自动处理;数据库可设置定时任务删除过期行。
  • 哈希加盐:对 RefreshToken 计算 hash 时拼接服务端固定盐值(如应用密钥),避免彩虹表攻击。

7.3 明文 vs 哈希

明文存储:校验时直接比对字符串,性能好。但要求 Redis/数据库访问必须严格 ACL 控制,且日志中不应记录明文 Token。

哈希存储:增加一层安全垫,即使存储泄露也无法直接使用 Token。但需要每次计算哈希后再比对,性能差异可忽略。推荐使用哈希存储

7.4 轮换策略

每次刷新后,旧 RefreshToken 必须立即失效(删除或标记为已使用)。这是防止重放攻击的关键措施。如果攻击者同时窃取了旧 RefreshToken 和新的 AccessToken,但新的 RefreshToken 已经轮换,旧 Token 失效则攻击者无法二次刷新。

重要:如果服务端发现一个 RefreshToken 被使用两次(即第二次刷新时,该 Token 已标记为使用),应判定为被盗用,将该用户的所有 RefreshToken 失效,并要求重新登录。

8. 踩坑记录:RefreshToken 过期处理、并发刷新、轮换策略

8.1 RefreshToken 过期处理

RefreshToken 本身也有有效期,如果用户在有效期内关闭浏览器再打开,AccessToken 可能已过期,但 RefreshToken 还在。此时前端应在启动时主动静默刷新一次,服务端返回新的 AccessToken 和 RefreshToken。

更优雅的做法是:前端在 RefreshToken 过期前(例如剩余 1 天),主动调用一个 /auth/refresh/status 接口来预判是否需要刷新。但实践中,大多数应用直接等 401 触发刷新即可,因为刷新接口是幂等的。

8.2 并发刷新问题

当同时多个 API 请求都返回 401 时,前端可能发起多个并发的 /auth/refresh 请求。这会导致:

  • 服务端收到重复的刷新请求,第一个请求成功轮换了 RefreshToken,后续请求携带的旧 RefreshToken 已被标记为使用,因此返回失败。
  • 多个失败导致前端误判为 RefreshToken 也过期,进入登录页。

解决方案:前端使用一个 pending promise 来去重——第一个 401 触发刷新时,后续所有 401 等待同一个刷新的 Promise 完成,然后重试。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
let refreshPromise = null;

api.interceptors.response.use(
response => response,
async error => {
const originalRequest = error.config;
if (error.response.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
if (!refreshPromise) {
refreshPromise = refreshToken().then(newToken => {
refreshPromise = null;
return newToken;
}).catch(() => {
refreshPromise = null;
localStorage.clear();
window.location.href = '/login';
});
}
const newToken = await refreshPromise;
originalRequest.headers['Authorization'] = `Bearer ${newToken}`;
return api(originalRequest);
}
return Promise.reject(error);
}
);

8.3 轮换策略陷阱

如果轮换时只更新而不删除旧 Token,攻击者可同时持有新旧两个 RefreshToken,获得双倍的有效期窗口。务必在生成新 RefreshToken 后,立即删除或标记旧记录。

另外,如果服务端采用无状态 JWT 作为 RefreshToken(即 JWT 本身携带过期信息,不做存储),则无法实现真正的轮换。这种情况下,攻击者同时持有新旧 Token 都能使用。建议始终使用服务端状态记录来控制轮换。

8.4 长期不活跃

用户关闭浏览器 7 天后,RefreshToken 过期,需要重新登录。如果希望允许用户长期保持会话,可使用滑动会话策略:每次刷新时更新 RefreshToken 的过期时间为 当前时间 + 7天。但需要注意,这会使令牌长期不失效,大屏显示等场景需配合其他安全措施(如 IP 绑定、设备指纹)。

9. OAuth2 RefreshToken 与 JWT 结合——企业级扩展

9.1 OAuth2 中的 RefreshToken

在 OAuth2 授权码流程中,Authorization Server 返回:

  • access_token:可以是 JWT 或不透明字符串。
  • refresh_token:通常是一个不透明随机字符串,需服务端查库校验。OAuth2 的刷新流程与自研双 Token 类似,但增加了 client_idclient_secret 的校验。

9.2 自研双 Token vs OAuth2 方案对比

维度 自研双 Token OAuth2(授权码模式)
适用场景 单一应用、内部系统 多系统 SSO、第三方授权
刷新验证 仅验证 RefreshToken 需要验证 client_id + client_secret + refresh_token
轮换要求 建议强制轮换 public client 强制轮换,confidential client 可选
无状态性 可支持(JWT),但轮换需查库 不支持无状态

9.3 集成建议

  • 对于企业内部系统,自研双 Token 足够,实现简单。
  • 如果需要对接多个外部应用(如第三方登录、SSO 网关),使用 OAuth2 框架(如 Spring Authorization Server, Keycloak)。

注意事项:OAuth2 RefreshToken 在不轮换的情况下,只要不发送刷新请求,旧 Token 一直有效。公共客户端(public client,如纯前端应用)要求强制轮换,以降低泄露风险。

10. 总结与拓展

10.1 核心要点回顾

  1. AccessToken 和 RefreshToken 的分工:短期 AccessToken 用于业务鉴权,长期 RefreshToken 仅在刷新接口使用。

  2. 轮换与失效:每次刷新后必须废弃旧 RefreshToken,服务端标记为已使用或删除,防止重放攻击。

  3. 安全存储:前端用 httpOnly Cookie 存储 RefreshToken,后端存储其哈希值,数据库/Redis 访问严格控制 ACL。

  4. 并发处理:前端用 pending promise 去重刷新请求,服务端加乐观锁或版本号防止同一 Token 被并发使用。

  5. 过期策略:RefreshToken 设置合理有效期(7~30天),结合滑动窗口或主动预刷新提升体验。

10.2 拓展思考

  • 绑定设备指纹:在 RefreshToken 记录中保存用户的 User-Agent、IP 段、设备 ID。刷新时比对,不一致则要求重新登录。可降低令牌被跨设备盗用的风险。

  • Device Grant 流程:对于无浏览器客户端(如移动 App、IoT 设备),可使用 OAuth2 设备授权码流程替代双 Token 模型。

  • 性能影响:每次刷新仅涉及一次 Redis 查询(或数据库查找),相比重新登录(可能涉及 LDAP、密码哈希等),开销更小。双 Token 模型对后端性能几乎无影响。

  • 推荐工具与框架

    • Spring Security:内置 refresh_token 扩展,结合 OAuth2AuthorizationService 即可快速实现。
  • Node.js:express-jwt + redis 实践轻量级实现,搭配 helmet 增强安全头。

    • 通用:Keycloak、Auth0 等服务可直接配置 RefreshToken 行为。

通过合理使用双 Token 模型,可以在不牺牲安全性的前提下,实现用户无感的会话续期,提升系统整体体验。本文提供的方案已在多个生产环境中实践,可根据业务需要选择适配。

总结

通过本文的学习,相信你已经对「JWT」有了更深入的理解。建议结合实际项目多加练习。如有疑问,欢迎交流!