Backend Development 24 min read

Implementing Token Silent Refresh with Spring Boot Gateway, Axios Interceptor, and Periodic Timer Monitoring

This article explains how to achieve seamless token silent refresh by handling expiration on the server side with a Spring Boot 3 gateway filter, on the client side with an Axios response interceptor, and by using a TypeScript timer to proactively refresh tokens before they expire, while also discussing the trade‑offs between server‑ and client‑side implementations.

Top Architect
Top Architect
Top Architect
Implementing Token Silent Refresh with Spring Boot Gateway, Axios Interceptor, and Periodic Timer Monitoring

When building an authentication server, a common challenge is implementing token silent refresh – automatically obtaining a new access token without user interaction to keep the session alive.

The author first lists three key questions: whether the refresh logic should reside on the server or client, how to extract the token expiration time, and how to resend the original request after obtaining a new token.

Server‑side gateway interceptor (Spring Boot 3, Java 17)

The gateway checks each request; if the token is expired it returns a custom status code (e.g., 511) to signal the client to refresh. The filter extends GlobalFilter and implements Ordered . Core logic:

@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();
        if (method.matches(HttpMethod.OPTIONS.name())) return chain.filter(exchange);
        if (SecurityAccessConstant.REQUEST_LOGGING_URI.equals(uri) && method.matches(HttpMethod.POST.name()))
            return chain.filter(exchange);
        String token = JWTHelper.getToken(request.getHeaders().getFirst(SecurityAccessConstant.HEADER_NAME_TOKEN));
        if (token != null) {
            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; }
}

The helper method isOutDate parses the JWT and returns true when the expiration date is before the current time, catching JwtException for invalid tokens.

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;
    }
}

Client‑side Axios interceptor

The Axios response interceptor reacts to status codes: 401 forces a logout, while 511 triggers a call to the refresh function to obtain a new token and retry the original 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.removeItem('isAuthenticated');
                window.sessionStorage.removeItem('token');
                window.sessionStorage.removeItem('refreshToken');
                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);
    }
);

The refresh function sends a raw Axios GET request to the authentication server, using the stored refreshToken in the Authorization header and returns the new access token.

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 '';
    }
}

Proactive token monitoring with a timer (TypeScript)

To avoid the asynchronous race condition where the original request finishes before the refreshed token is applied, the author introduces a MyTimer class that periodically checks the remaining token lifetime and calls refresh when the remaining time falls below a configurable threshold.

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; // 1 min threshold
    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 (e) { console.error('刷新失败:', e); }
                }
            } 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(); }

During login, the timer is started with a 30‑second interval and a 30‑second check threshold, ensuring the token is refreshed well before it expires.

Server‑side enhancements

The backend now returns the token expiration timestamp together with the access token and refresh token, allowing the client to compare the stored tokenExpire value with the current time instead of decoding the JWT.

public static Date getExpirationDate(String token) {
    if (StringUtil.isBlank(token)) return null;
    Claims claims = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token).getBody();
    return claims.getExpiration();
}

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]);

Choosing between server‑side and client‑side refresh

Server‑side advantages: better security, centralized management, reduced client complexity, and consistent state across devices.

Client‑side advantages: immediate token renewal, offline support, flexibility to adjust refresh strategy per user behavior, and reduced load on the authentication server.

The article concludes that the appropriate approach depends on the specific scenario; high‑security systems often prefer server‑side handling, while mobile or lightweight web apps may benefit from client‑side proactive refresh.

JavaScriptAuthenticationAxiosSpringBootrefreshtoken
Top Architect
Written by

Top Architect

Top Architect focuses on sharing practical architecture knowledge, covering enterprise, system, website, large‑scale distributed, and high‑availability architectures, plus architecture adjustments using internet technologies. We welcome idea‑driven, sharing‑oriented architects to exchange and learn together.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.