Implementing Silent Token Refresh in Vue and Node.js Applications
User experience suffers when an applciation forces a logout due to an expired authentication token. Silent refresh addresses this by transparently renewing tokens in the background.
Token Refresh Strategies
Redis-based Token Extension
A common backend-driven approach stores tokens in Redis with a confiugrable Time-To-Live (TTL). Each valid request resets the token's expiry, maintaining a single active token. The client remains unaware of the renewal process.
Dual-Token Archietcture
This client-managed strategy employs two tokens: a short-lived Access Token and a longer-lived Refresh Token.
Mechanism
- The client authenticates with credentials, receiving both tokens upon success.
- The Access Token is attached to API requests. The backend validates it.
- If the Access Token is invalid, the client uses the Refresh Token to request a new pair.
- The backend validates the Refresh Token. If valid, new tokens are issued. If invalid, the user must re-authenticate.
- The client stores the new tokens and retries the original request.
Implementation Example
Backend (Node.js with Koa)
Token generation utility (tokenManager.js):
const jwt = require('jsonwebtoken');
const SECRET_KEY = 'your_secure_secret_key';
const ACCESS_EXPIRY = 5; // Short expiry for access token
const REFRESH_EXPIRY = 15; // Longer expiry for refresh token
const generateAccessToken = (userData = {}) => {
return jwt.sign(userData, SECRET_KEY, { expiresIn: ACCESS_EXPIRY });
};
const generateRefreshToken = (userData = {}) => {
return jwt.sign(userData, SECRET_KEY, { expiresIn: REFRESH_EXPIRY });
};
module.exports = { SECRET_KEY, generateAccessToken, generateRefreshToken };
Middleware for token validation (authMiddleware.js):
const { SECRET_KEY } = require('./tokenManager');
const jwt = require('jsonwebtoken');
const publicPaths = ['/authenticate', '/renew-token'];
const isPublicPath = (url) => publicPaths.includes(url);
const validateAccessToken = async (ctx, next) => {
if (isPublicPath(ctx.path)) return await next();
const token = ctx.request.headers['authorization'];
if (!token) {
ctx.status = 401;
ctx.body = { error: 'No token provided' };
return;
}
try {
jwt.verify(token, SECRET_KEY);
await next();
} catch (err) {
ctx.status = 401;
ctx.body = { error: 'Invalid or expired token' };
}
};
module.exports = validateAccessToken;
Frontend (Vue 3 with Axios)
Storage helper (authStorage.js):
const ACCESS_KEY = 'app_access_token';
const REFRESH_KEY = 'app_refresh_token';
export const storeTokens = (access, refresh) => {
localStorage.setItem(ACCESS_KEY, access);
localStorage.setItem(REFRESH_KEY, refresh);
};
export const getAccessToken = () => localStorage.getItem(ACCESS_KEY);
export const getRefreshToken = () => localStorage.getItem(REFRESH_KEY);
export const clearTokens = () => {
localStorage.removeItem(ACCESS_KEY);
localStorage.removeItem(REFRESH_KEY);
};
Token refresh orchestration (refreshHandler.js):
import { getRefreshToken, clearTokens, storeTokens } from './authStorage';
import apiClient from './apiClient';
let pendingRequests = [];
let isRefreshing = false;
export const queueRequest = (request) => {
pendingRequests.push(request);
};
export const replayRequests = () => {
pendingRequests.forEach(callback => callback());
pendingRequests = [];
};
export const refreshAccessToken = () => {
if (isRefreshing) return;
isRefreshing = true;
const refreshToken = getRefreshToken();
if (!refreshToken) {
clearTokens();
isRefreshing = false;
return;
}
apiClient.get('/renew-token', {
headers: { 'X-Refresh-Token': refreshToken }
}).then(response => {
if (response.data.accessToken) {
storeTokens(response.data.accessToken, response.data.refreshToken);
replayRequests();
}
isRefreshing = false;
}).catch(() => {
clearTokens();
isRefreshing = false;
});
};
Configured HTTP client (apiClient.js):
import axios from 'axios';
import { getAccessToken } from './authStorage';
import { queueRequest, refreshAccessToken } from './refreshHandler';
const client = axios.create({
baseURL: 'http://localhost:3000/api',
timeout: 10000
});
client.interceptors.request.use(config => {
const token = getAccessToken();
if (token) config.headers.Authorization = token;
return config;
});
client.interceptors.response.use(
response => response.data,
error => {
const { config, response } = error;
if (response?.status === 401 && response.data?.error === 'Invalid or expired token') {
return new Promise(resolve => {
queueRequest(() => resolve(client(config)));
refreshAccessToken();
});
}
return Promise.reject(error);
}
);
export default client;
Vue Component Usage (App.vue):
<script setup>
import { loginUser, fetchProtectedData } from './services/api';
import { storeTokens } from './utils/authStorage';
const handleLogin = async () => {
const result = await loginUser({ username: 'user', password: 'pass' });
storeTokens(result.accessToken, result.refreshToken);
};
const handleRequest = async () => {
const data = await fetchProtectedData();
console.log('Data received:', data);
};
</script>
<template>
<button @click="handleLogin">Authenticate</button>
<button @click="handleRequest">Fetch Data</button>
</template>