Transparent Token Refresh: Client‑Side and Server‑Side Implementations
This article explains how to implement seamless, invisible token refresh for authentication systems, covering client‑side strategies using Axios interceptors and timers, server‑side gateway filters with Spring Boot, code examples for detecting token expiration, obtaining new tokens, and choosing between client and server approaches based on security and performance considerations.
1. Introduction
When building an authentication server, a common challenge is the invisible (no‑user‑perception) refresh of access tokens. A short‑lived token is used for permission checks, while a longer‑lived refreshToken is used to obtain a new short token.
Q1: Should the refresh logic be implemented on the server or the client?
Q2: How to obtain the token's expiration time when the token cannot be parsed?
Q3: After obtaining a new token, how to resend the original request and return its result to the original caller?
The following sections provide a complete solution to these questions.
2. Client‑Side Implementation
2.1 Initial Version
The gateway intercepts every request. If the token is expired, it returns a specific status code (e.g., 511) to inform the client.
2.1.1 Server‑Side Gateway Filter
Implemented with Spring Boot 3 + Java 17 by extending GlobalFilter :
@Component
public class MyAccessFilter implements GlobalFilter, Ordered {
@Override
public Mono
filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
String uri = request.getURI().getPath();
HttpMethod method = request.getMethod();
// OPTIONS pass through
if (method.matches(HttpMethod.OPTIONS.name()))
return chain.filter(exchange);
// Login request pass through
if (SecurityAccessConstant.REQUEST_LOGGING_URI.equals(uri) && method.matches(HttpMethod.POST.name()))
return chain.filter(exchange);
// Get token
String token = JWTHelper.getToken(request.getHeaders().getFirst(SecurityAccessConstant.HEADER_NAME_TOKEN));
if (token != null) {
// Check expiration
if (!JWTHelper.isOutDate(token)) {
return chain.filter(exchange);
} else {
if (!SecurityAccessConstant.REQUEST_REFRESH.equals(uri))
return ResponseUtils.out(exchange, ResultData.fail(ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getCode(), ResultCodeEnum.NEED_TO_REFRESH_TOKEN.getMessage()));
return ResponseUtils.out(exchange, ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
}
}
return ResponseUtils.out(exchange, ResultData.fail(ResultCodeEnum.RC401.getCode(), ResultCodeEnum.RC401.getMessage()));
}
@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}2.1.1.1 Solving Q2 (Token Expiration Check)
When parsing fails, catch JwtException and treat the token as expired:
public static boolean isOutDate(String token) {
try {
Jws
claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
Date expirationDate = claimsJws.getBody().getExpiration();
return expirationDate.before(new Date());
} catch (JwtException e) {
return true; // token invalid or corrupted
}
}2.1.2 Axios Interceptor
The client intercepts response status codes. 401 triggers a login redirect, while 511 triggers a refreshToken request:
service.interceptors.response.use(
response => response.data.data,
async error => {
if (!error.response) return Promise.reject(error);
const status = error.response.status;
const authStore = useAuthStore();
let message = '';
switch (status) {
case 401:
authStore.reset();
window.sessionStorage.clear();
message = 'token失效,请重新登录';
window.location.href = '/auth/login';
break;
case 511:
try {
const data = refresh();
if (data !== null) {
data.then(value => {
if (value !== '') {
console.log('刷新 token 成功', value);
window.sessionStorage.setItem('token', value);
error.config.headers['Authorization'] = 'Bearer ' + value;
return service(error.config);
}
}).catch(err => console.error(err));
}
} catch (err) {
console.log('请求刷新 token 失败', err);
router.push('/login');
}
break;
case 403:
message = '拒绝访问';
break;
case 404:
message = '请求地址错误';
break;
case 500:
message = '服务器故障';
break;
default:
message = '网络连接故障';
}
Message.error(message);
return Promise.reject(error);
}
);2.1.3 Refresh Token Method
Uses a raw Axios request to avoid the interceptor loop:
/**
* Refresh token
* @returns new token string or empty string on failure
*/
export async function refresh(): Promise
{
const refreshToken = window.sessionStorage.getItem('refreshToken');
if (refreshToken === undefined) return '';
try {
const response = await axios({
method: 'GET',
url: 'http://127.0.0.1:9001/api/simple/cloud/access/refresh',
headers: { Authorization: `Bearer ${refreshToken}` }
});
if (response.data) {
return response.data.data;
} else {
return '';
}
} catch (error) {
console.log(error);
return '';
}
}2.2 Improved Version – Periodic Token Monitoring
Instead of reacting only on failure, a timer checks the token’s remaining lifetime and refreshes it proactively when it falls below a threshold.
2.2.1 Timer Class
import { refresh } from '@/api/system/auth/index';
import { jwtDecode } from 'jwt-decode';
export class MyTimer {
private timerId: any | null = null;
private delay: number = 30000; // default 30 s
private minCheck: number = 60000; // default 1 min
private static instance: MyTimer;
public static getInstance(): MyTimer {
if (!MyTimer.instance) MyTimer.instance = new MyTimer();
return MyTimer.instance;
}
private constructor() {}
start(): void {
this.timerId = setInterval(async () => {
const currentToken = window.sessionStorage.getItem('token');
if (currentToken) {
const tokenExpireStr = window.sessionStorage.getItem('tokenExpire') as string;
const expirationTime = parseInt(tokenExpireStr, 10);
const timeRemaining = expirationTime - Date.now();
if (timeRemaining <= this.minCheck) {
try {
await refresh();
} catch (error) {
console.error('刷新失败:', error);
window.sessionStorage.clear();
Message.error('token refresh got some problem, please login');
window.location.href = '/auth/login';
}
}
} else {
Message.error('token invalidate, please login');
window.location.href = '/auth/login';
}
}, this.delay);
}
stop(): void {
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
setDelay(delay: number): void { this.delay = delay; }
setMinCheck(minCheck: number): void { this.minCheck = minCheck; }
}
export const myFilterInstance = MyTimer.getInstance();
export function onPageRender() {
myFilterInstance.stop();
myFilterInstance.start();
}2.2.2 Using the Timer in Login Flow
import { MyTimer } from '@/utils/tokenMonitor';
const submit = () => {
if (validate()) {
login(formData).then(data => {
const authStore = useAuthStore();
authStore.setToken(data.token);
window.sessionStorage.setItem('token', data.token);
window.sessionStorage.setItem('refreshToken', data.refreshToken);
authStore.setIsAuthenticated(true);
const clock = new MyTimer();
clock.start();
init({ message: 'logged in success', color: 'success' });
push({ name: 'dashboard' });
}).catch(() => {
init({ message: 'logged in fail, please check carefully!', color: '#FF0000' });
});
} else {
Message.error('error submit!!');
return false;
}
};2.3 Final Timer Version (Server‑Side Adjustments)
2.3.1 Server‑Side Token Helpers
public static Date getExpirationDate(String token) {
if (StringUtil.isBlank(token)) return null;
Claims claims = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody();
return claims.getExpiration();
}
// When issuing token
String[] tokenArray = JWTHelper.createToken(sysUser.getId(), sysUser.getEmail(), permsList);
map.put("token", tokenArray[0]);
map.put("tokenExpire", JWTHelper.getExpirationDate(tokenArray[0]).getTime());
map.put("refreshToken", tokenArray[1]);2.3.2 Updated Monitoring Class (Singleton)
import { refresh } from '@/api/system/auth/index';
export class MyTimer {
private timerId: any | null = null;
private delay: number = 30000;
private minCheck: number = 60000;
private static instance: MyTimer;
public static getInstance(): MyTimer { if (!MyTimer.instance) MyTimer.instance = new MyTimer(); return MyTimer.instance; }
private constructor() {}
start(): void { /* same logic as previous version, using tokenExpire from server */ }
stop(): void { if (this.timerId !== null) { clearInterval(this.timerId); this.timerId = null; } }
setDelay(d: number): void { this.delay = d; }
setMinCheck(m: number): void { this.minCheck = m; }
}
export const myFilterInstance = MyTimer.getInstance();
export function onPageRender() { myFilterInstance.stop(); myFilterInstance.start(); }3. Server‑Side Implementation
The gateway checks token expiration; if expired, it uses WebClient to call the authentication server with the refreshToken and updates the request header:
// Request new token from auth server
Mono
newTokenMono = WebClient.create().get()
.uri(buildUri(SecurityAccessConstant.WEB_REQUEST_TO_AUTH_URL + SecurityAccessConstant.REQUEST_REFRESH, new String[]{"refreshToken", token}))
.retrieve()
.bodyToMono(ResultData.class);
AtomicBoolean isPass = new AtomicBoolean(false);
newTokenMono.subscribe(resultData -> {
if (resultData.getCode() == "200") {
exchange.getRequest().getHeaders().set(SecurityAccessConstant.HEADER_NAME_TOKEN, SecurityAccessConstant.TOKEN_PREFIX + resultData.getData());
isPass.set(true);
}
}).dispose();
if (isPass.get()) {
return chain.filter(exchange.mutate().request().build());
}4. Choosing Between Client and Server Refresh
Server‑Side Advantages: better security, centralized management, reduced client complexity, consistent state across devices.
Client‑Side Advantages: immediate response, offline support, flexibility, reduced server load.
The appropriate approach depends on the specific scenario and requirements.
Java Architect Essentials
Committed to sharing quality articles and tutorials to help Java programmers progress from junior to mid-level to senior architect. We curate high-quality learning resources, interview questions, videos, and projects from across the internet to help you systematically improve your Java architecture skills. Follow and reply '1024' to get Java programming resources. Learn together, grow together.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.