Implementing JWT Token Refresh Strategies Self-Hosted
What You’ll Need
- A self-hosted virtual private server like a Hetzner VPS or Contabo VPS running Ubuntu 22.04 LTS
- Alternatively, a cloud server from DigitalOcean
- A registered domain name pointing to your server using Namecheap
- Node.js runtime (v18.x or v20.x LTS) installed on your instance
- Redis server (v7.x or higher) for state management and token tracking
Table of Contents
- Understanding Dual-Token Architecture and Refresh Token Rotation
- Setting Up Redis and Key Storage Infrastructure
- Building the Full Authentication Node.js Server
- Implementing Token Rotation and Reuse Detection
- Handling Edge Cases and Client Retry Strategies
- Getting Started
Understanding Dual-Token Architecture and Refresh Token Rotation
When I deploy self-hosted services, security model design is always my highest priority. JSON Web Tokens (JWTs) are popular because they are stateless. A server signs a payload using a secret key, and any backend service with that key can verify the signature without querying a central database.
However, statelessness introduces a major vulnerability. If an access token is exfiltrated by a attacker, it remains valid until its expiration time. You cannot easily revoke a purely stateless JWT without maintaining a global revocation list, which destroys the stateless benefit.
To solve this, I rely on a dual-token architecture paired with Refresh Token Rotation (RTR). In this design:
- Access Tokens are short-lived (5 to 15 minutes). They contain user scopes and claims. They are sent in headers for API requests.
- Refresh Tokens are long-lived (7 to 30 days). They are stored securely in HTTP-only, SameSite cookies and are only used at a dedicated refresh endpoint to obtain new access tokens.
- Refresh Token Rotation requires that every time a refresh token is exchanged, it is invalidated and replaced with a brand-new refresh token.
- Token Families track token genealogy. Every time a user logs in, a new
familyIdis created. All rotated tokens derived from that login session share thisfamilyId. If an old, already-used refresh token is presented, the server detects token theft, immediately revokes the entirefamilyId, and forces all sessions for that user family to re-authenticate.
Transport security is equally important here. You must always run your authentication endpoints behind TLS encryption. To secure your transport layer, check out my guide on How to Implement Mutual TLS Authentication with Nginx for deep server hardening.
Setting Up Redis and Key Storage Infrastructure
To manage refresh token states, family IDs, and rotation tracking efficiently, I use Redis. Redis gives us sub-millisecond read and write speeds along with native Key-Value Expiration (TTL), which automatically cleans up expired token records without running manual database cron jobs.
If you are running your environment on a Hetzner VPS or Contabo VPS, you can quickly spin up a production-ready Redis instance using Docker Compose.
Create a docker-compose.yml file on your server:
version: '3.8'
services:
redis:
image: redis:7-alpine
container_name: auth_redis
restart: always
command: redis-server --save 60 1 --loglevel notice --requirepass "SuperSecureRedisPassword2026!"
ports:
- "127.0.0.1:6379:6379"
volumes:
- redis_data:/data
volumes:
redis_data:
Start the container by running:
docker compose up -d
This configuration binds Redis strictly to localhost (127.0.0.1) so it is not exposed to the public internet, enforces password authentication, and persists data to a Docker volume.
💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.
Building the Full Authentication Node.js Server
Now, let us build a production Node.js authentication service. Create a project directory, initialize package metadata, and install the required modules:
mkdir selfhosted-auth-service
cd selfhosted-auth-service
npm init -y
npm install express jsonwebtoken redis cookie-parser dotenv crypto
Create a file named .env in the project root to store secret variables securely:
PORT=4000
JWT_ACCESS_SECRET=e9821f7c32a104b209d66141a4a1c5d6e2b801a3421190cd5fef61a9c8b73121
JWT_REFRESH_SECRET=7f11902c34a91b22e1180129c7162b100234a5d6f1a8c90001aefbc20182c1e4
REDIS_URL=redis://:SuperSecureRedisPassword2026!@127.0.0.1:6379
Next, create server.js. This script implements the entire token generation, verification, and rotation logic without relying on external third-party identity providers.
require('dotenv').config();
const express = require('express');
const jwt = require('jsonwebtoken');
const redis = require('redis');
const cookieParser = require('cookie-parser');
const crypto = require('crypto');
const app = express();
app.use(express.json());
app.use(cookieParser());
const redisClient = redis.createClient({
url: process.env.REDIS_URL
});
redisClient.on('error', (err) => console.error('Redis Client Error:', err));
redisClient.connect().then(() => console.log('Connected to Redis successfully.'));
const ACCESS_TOKEN_EXPIRY = '15m';
const REFRESH_TOKEN_EXPIRY_SECONDS = 7 * 24 * 60 * 60; // 7 Days
const GRACE_PERIOD_SECONDS = 10; // Allow parallel requests during client token refresh
function generateAccessToken(userId) {
return jwt.sign(
{ userId: userId },
process.env.JWT_ACCESS_SECRET,
{ expiresIn: ACCESS_TOKEN_EXPIRY }
);
}
function generateRefreshToken() {
return crypto.randomBytes(40).toString('hex');
}
async function storeRefreshToken(tokenId, userId, familyId, isUsed = false) {
const key = `refresh_token:${tokenId}`;
const payload = JSON.stringify({
userId: userId,
familyId: familyId,
used: isUsed
});
await redisClient.set(key, payload, {
EX: REFRESH_TOKEN_EXPIRY_SECONDS
});
await redisClient.sAdd(`family:${familyId}`, key);
await redisClient.expire(`family:${familyId}`, REFRESH_TOKEN_EXPIRY_SECONDS);
}
async function revokeTokenFamily(familyId) {
const familyKey = `family:${familyId}`;
const tokenKeys = await redisClient.sMembers(familyKey);
if (tokenKeys.length > 0) {
await redisClient.del(tokenKeys);
}
await redisClient.del(familyKey);
}
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
if (username !== 'admin' || password !== 'password123') {
return res.status(401).json({ error: 'Invalid credentials' });
}
const userId = 'user_994821';
const familyId = crypto.randomUUID();
const refreshTokenId = generateRefreshToken();
const accessToken = generateAccessToken(userId);
await storeRefreshToken(refreshTokenId, userId, familyId, false);
res.cookie('refreshToken', refreshTokenId, {
httpOnly: true,
secure: false, // Set to true in production behind TLS
sameSite: 'strict',
maxAge: REFRESH_TOKEN_EXPIRY_SECONDS * 1000
});
return res.json({
accessToken: accessToken,
expiresIn: '15m'
});
});
app.post('/api/refresh', async (req, res) => {
const refreshTokenId = req.cookies.refreshToken;
if (!refreshTokenId) {
return res.status(401).json({ error: 'Refresh token cookie missing' });
}
const tokenKey = `refresh_token:${refreshTokenId}`;
const tokenDataString = await redisClient.get(tokenKey);
if (!tokenDataString) {
return res.status(403).json({ error: 'Invalid or expired refresh token' });
}
const tokenData = JSON.parse(tokenDataString);
if (tokenData.used) {
console.warn(`SECURITY ALERT: Reuse detected for token ${refreshTokenId}. Revoking family ${tokenData.familyId}`);
await revokeTokenFamily(tokenData.familyId);
res.clearCookie('refreshToken');
return res.status(403).json({ error: 'Security breach detected. Token reuse flagged. Please log in again.' });
}
await redisClient.set(tokenKey, JSON.stringify({
userId: tokenData.userId,
familyId: tokenData.familyId,
used: true
}), {
EX: GRACE_PERIOD_SECONDS
});
const newRefreshTokenId = generateRefreshToken();
const newAccessToken = generateAccessToken(tokenData.userId);
await storeRefreshToken(newRefreshTokenId, tokenData.userId, tokenData.familyId, false);
res.cookie('refreshToken', newRefreshTokenId, {
httpOnly: true,
secure: false, // Set to true in production
sameSite: 'strict',
maxAge: REFRESH_TOKEN_EXPIRY_SECONDS * 1000
});
return res.json({
accessToken: newAccessToken,
expiresIn: '15m'
});
});
app.post('/api/logout', async (req, res) => {
const refreshTokenId = req.cookies.refreshToken;
if (refreshTokenId) {
const tokenKey = `refresh_token:${refreshTokenId}`;
const tokenDataString = await redisClient.get(tokenKey);
if (tokenDataString) {
const tokenData = JSON.parse(tokenDataString);
await revokeTokenFamily(tokenData.familyId);
}
}
res.clearCookie('refreshToken');
return res.json({ message: 'Successfully logged out' });
});
app.get('/api/protected', (req, res) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or malformed access token' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, process.env.JWT_ACCESS_SECRET);
return res.json({ message: 'Access granted to protected endpoint', userId: decoded.userId });
} catch (err) {
return res.status(401).json({ error: 'Token expired or invalid' });
}
});
const PORT = process.env.PORT || 4000;
app.listen(PORT, () => {
console.log(`Auth server running on port ${PORT}`);
});
Start your server locally or on your self-hosted VPS instance:
node server.js
Implementing Token Rotation and Reuse Detection
Let us analyze what happens inside our authentication pipeline when an attacker attempts to steal a token.
When a legit user calls /api/refresh:
- The server reads the existing refresh token ID from the HTTP-only cookie.
- It verifies that the token exists in Redis and that
used === false. - The server immediately marks the current token as
used === truewith a short grace TTL (10 seconds). - It issues a new refresh token under the exact same
familyIdand returns a fresh access token.
Now suppose an attacker intercepts the original refresh token and attempts to use it 30 seconds later:
- The attacker hits
/api/refreshwith the stolen, already-used refresh token. - The server inspects Redis. The token exists, but its status flag is set to
used: true. - The reuse detection logic triggers immediately.
- The server calls
revokeTokenFamily(tokenData.familyId). - Redis executes a set operation deleting all refresh token keys stored inside the
family:UUIDtracking set. - The legitimate user and the attacker are both completely invalidated on their next request, safely blocking unauthorized session hijacking.
Automated backend tasks, microservices, or external worker scripts often rely on these token refresh endpoints. For instance, if you run worker agents like those discussed in Building Production Web Scraping Pipelines With Python, your client services must handle automatic token renewal seamlessly without crashing when tokens expire.
Handling Edge Cases and Client Retry Strategies
In production systems, network connectivity is never perfect. Client-side edge cases are the most common source of false-positive reuse detections.
Imagine a mobile client on a weak cellular connection. The client sends a request to /api/refresh. The server processes the request, rotates the token in Redis, and sends back the HTTP response. However, the client experiences a packet drop before receiving the response cookie.
If the client immediately retries using the original token, a strict zero-grace-period policy would flag this as token theft, destroying the user session unnecessarily.
To prevent this issue, I introduced GRACE_PERIOD_SECONDS = 10 in our backend code:
- When a refresh token is rotated, we do not delete it immediately.
- We update its status in Redis to
used: trueand set an expiration TTL of 10 seconds. - If a duplicate request arrives within that 10-second window, you can optionally configure your server to re-issue the exact same active child token rather than immediately revoking the entire family.
Furthermore, client applications should implement robust retry algorithms. If a network disruption occurs during an authentication exchange, clients should fall back gracefully. Refer to my detailed breakdown on Handling Webhook Retries Using Exponential Backoff Strategies to understand how client-side retry logic and backoff delay curves prevent server overload.
Getting Started
Building and hosting your own authentication service gives you complete control over user data and zero dependencies on third-party SaaS pricing tiers.
To deploy this setup in production:
- Provision a high-performance VPS host on Hetzner VPS, Contabo VPS, or DigitalOcean.
- Domain setup and DNS management can be configured using Namecheap.
- Place your Node.js application behind an Nginx reverse proxy configured with SSL certificates and mTLS protection for maximum network isolation.
Outsource Your Automation
Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-HUGO for priority. Or DM at chasebot.online.
Want to automate this yourself?
Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.
Want this engine running on your own VPS?
This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.
Get System 3