前端与 Spring Boot 后端无感 Token 刷新 - 从原理到全栈实践

1. 前言
在前后端分离的应用中,常用的身份认证方案是基于 JWT(JSON Web Token)。在保证安全性的同时,短生命周期的 Access Token 会带来频繁登录的体验痛点。为了解决这个问题,我们引入 Refresh Token 并结合无感刷新机制,让客户端在 Access Token 过期时自动刷新,而无需用户手动重新登录。
2. 为什么要无感刷新
在基于 Token 的用户认证系统中,通常会设计两种 Token:
Access Token:用于访问资源,有效期短(通常 15-30 分钟)
Refresh Token:用于获取新 Access Token,有效期长(通常 7 天)
传统 Token 机制存在两大痛点:
频繁强制退出:Access Token 过期时用户需重新登录
安全隐患:延长 Access Token 有效期会增加安全风险
无感刷新解决了这些问题:
用户体验优先
Access Token 常设很短(如 5–15 分钟),若不自动刷新,登录态会频繁过期,用户被迫'重新登录',体验极差
安全与性能平衡
短生命周期的 Access Token 能减少被截获滥用的风险
结合 Refresh Token(相对较长有效期),可以在安全与便捷间找到最佳点
前后端解耦
通过前端拦截器统一处理过期场景,无须在各业务请求中散落重复逻辑
后端专注提供刷新接口与失效策略,无需关心前端实现细节
3. 无感刷新原理
3.1 无感刷新流程

3.2 关键技术点
双 Token 机制
Access Token:短时有效,携带用户身份和权限
Refresh Token:长期有效,专用于换取新的 Access Token
拦截与重试
- 前端在每次 API 请求中携带 Access Token;
- 若响应为 401 Unauthorized(或后端自定义过期码),前端拦截器自动调用刷新 token 接口,用 Refresh Token 获取新一对 Token;
- 获取成功后,前端重新发起失败的原始请求,用户无感知。
后端安全策略
将 Refresh Token 写入 Redis,并在刷新时做一次性或者滑动过期(可选)校验;
旧 Refresh Token 刷新后失效,防止被盗用。
4. 前端实现
下面以 Axios 为例演示拦截器逻辑。我们将 Tokens 保存在 localStorage 或者更安全的 HttpOnly Cookie 中(此处示例用 localStorage 方便演示)
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
});
function getAccessToken() {
return localStorage.getItem('access_token');
}
function getRefreshToken() {
return localStorage.getItem('refresh_token');
}
function setTokens({ accessToken, refreshToken }) {
localStorage.setItem('access_token', accessToken);
localStorage.setItem('refresh_token', refreshToken);
}
api.interceptors.request.use(config => {
const token = getAccessToken();
if (token) config.headers['Authorization'] = `Bearer ${token}`;
return config;
});
let isRefreshing = false;
let subscribers = [];
function onRefreshed(newToken) {
subscribers.forEach(cb => cb(newToken));
subscribers = [];
}
function addSubscriber(cb) {
subscribers.push(cb);
}
api.interceptors.response.use(res => res, error => {
const { config, response } = error;
if (response && response.status === 401 && !config._retry) {
if (isRefreshing) {
return new Promise(resolve => {
addSubscriber(token => {
config.headers['Authorization'] = `Bearer ${token}`;
resolve(api(config));
});
});
}
config._retry = true;
isRefreshing = true;
return api.post('/auth/refresh', { refreshToken: getRefreshToken() })
.then(res => {
const { accessToken, refreshToken } = res.data;
setTokens({ accessToken, refreshToken });
isRefreshing = false;
onRefreshed(accessToken);
config.headers['Authorization'] = `Bearer ${accessToken}`;
return api(config);
})
.catch(err => {
isRefreshing = false;
window.location.href = '/login';
return Promise.reject(err);
});
}
return Promise.reject(error);
});
export default api;
要点说明
isRefreshing 和 subscribers 用于解决多个并发 401 时只发送一次刷新请求;
_retry 标记避免无限循环;
刷新失败后,需清除本地登录态并跳转到登录页。
5. 后端实现
5.1 基础依赖(pom.xml)
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
io.jsonwebtoken
jjwt-api
0.11.5
io.jsonwebtoken
jjwt-impl
0.11.5
runtime
io.jsonwebtoken
jjwt-jackson
0.11.5
runtime
5.2 数据库与实体(存储用户可选)
这里就简单模拟用户,仅有用户名和密码为例
CREATE TABLE user_account (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL
);
5.3 Redis 存储 Refresh Token
我们用 Redis 的 String,Key 为 refresh:{userId},Value 存 JSON { token, expireTime }
5.4 JWT 工具类
@Component
public class JwtUtil {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.access.expire}")
private long accessExpire;
@Value("${jwt.refresh.expire}")
private long refreshExpire;
public String generateAccessToken(Long userId) {
return Jwts.builder()
.setSubject(userId.toString())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + accessExpire))
.signWith(Keys.hmacShaKeyFor(secret.getBytes()))
.compact();
}
public String generateRefreshToken(Long userId) {
return Jwts.builder()
.setSubject(userId.toString())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + refreshExpire))
.signWith(Keys.hmacShaKeyFor(secret.getBytes()))
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(secret.getBytes())
.build()
.parseClaimsJws(token)
.getBody();
}
}
5.5 刷新服务
@Service
public class AuthService {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private StringRedisTemplate redis;
public Tokens login(String username, String password) {
Long userId = ;
String accessToken = jwtUtil.generateAccessToken(userId);
String refreshToken = jwtUtil.generateRefreshToken(userId);
String key = "refresh:" + userId;
redis.opsForValue().set(key, refreshToken, jwtUtil.getRefreshExpire(), TimeUnit.MILLISECONDS);
return new Tokens(accessToken, refreshToken);
}
public Tokens refresh(String refreshToken) {
Claims claims = jwtUtil.parseToken(refreshToken);
Long userId = Long.parseLong(claims.getSubject());
String key = "refresh:" + userId;
redis.opsForValue().get(key);
(cached == || !cached.equals(refreshToken)) {
();
}
jwtUtil.generateAccessToken(userId);
jwtUtil.generateRefreshToken(userId);
redis.opsForValue().set(key, newRefresh, jwtUtil.getRefreshExpire(), TimeUnit.MILLISECONDS);
(newAccess, newRefresh);
}
}
5.6 控制器 Controller
@RestController
@RequestMapping("/api/auth")
public class AuthController {
@Autowired
private AuthService authService;
@PostMapping("/login")
public Tokens login(@RequestBody LoginReq req) {
return authService.login(req.getUsername(), req.getPassword());
}
@PostMapping("/refresh")
public Tokens refresh(@RequestBody Map<String, String> body) {
return authService.refresh(body.get("refreshToken"));
}
}
@Data
class LoginReq {
private String username, password;
}
@Data
@AllArgsConstructor
class Tokens {
private String accessToken;
private String refreshToken;
}
5.7 JWT 验证过滤器
由于验证并非本文的重点,可参考相关 Spring Security 文档学习,这里仅提供思路:
在每次请求拦截中,解析 Access Token 并将用户信息放入 SecurityContext,若过期则交由前端刷新逻辑处理。
6. 结语
本文详细介绍了无感 Token 刷新的核心原理,以及前端 Axios 拦截器与后端 Spring Boot + MyBatis-Plus + Redis 的完整示例代码。通过双 Token、Redis 校验与拦截重试,你可以在保证安全性的同时,给用户带来无感登录过期刷新的体验。
后续可继续优化:
- Refresh Token 滑动过期:每次刷新延长有效期;
- Refresh Token 一次性使用:每个旧 Token 只能刷新一次;
- 前端多 tab 协调:同域下可共享刷新状态,避免重复刷新;
- 安全加固:结合 IP、UA 风控,防止 Token 被盗用。