Implementing JWT Refresh Token Rotation In Express

Implementing JWT Refresh Token Rotation In Express

What You’ll Need

Table of Contents

Understanding Refresh Token Rotation Architecture

JSON Web Tokens (JWTs) are the standard choice for authentication in modern web applications. However, stateless access tokens introduce a classic security trade off. If you issue long-lived access tokens, a stolen token gives an attacker access until it expires. If you issue short-lived access tokens, users must re-enter credentials constantly.

The standard solution uses two tokens: a short-lived access token (valid for 15 minutes) and a longer-lived refresh token (valid for 7 days). But what happens when an attacker steals the refresh token?

This is where Refresh Token Rotation (RTR) becomes essential. Every time a client sends a refresh token to obtain a new access token, the authentication server issues both a new access token AND a new refresh token. Simultaneously, the old refresh token is invalidated.

If a malicious actor steals a refresh token and attempts to use it after the legitimate user has already refreshed their session, the server detects token reuse. Because the old token is marked as already used, the system triggers an automatic breach response: it immediately revokes the entire token family, logging out both the attacker and the legitimate user.

Client                        Server                        Database / Redis
  |                             |                                  |
  |--- 1. POST /refresh ------->|                                  |
  |    (Sends Token R1)         |--- 2. Check Token R1 Status ---->|
  |                             |<-- Token Valid (Not Used) -------|
  |                             |                                  |
  |                             |--- 3. Mark R1 as Used ---------->|
  |                             |--- 4. Store New Token R2 ------->|
  |                             |                                  |
  |<-- 5. Return R2 & Access ---|                                  |

When designing state tracking in distributed systems, handling token state requires strict atomicity. Much like managing idempotency keys in high throughput applications (which I cover in detail in my guide on Handling Webhook Event Deduplication in Distributed Systems), tracking refresh token usage demands synchronized state storage across all server instances.

Setting Up the Express Infrastructure

To deploy a secure authentication server, launch a clean cloud server instance on Hetzner VPS or DigitalOcean. Node.js and Redis will manage our active token state.

First, initialize a new Node.js project and install the necessary dependencies:

mkdir express-jwt-rotation
cd express-jwt-rotation
npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser dotenv ioredis
npm install --save-dev nodemon

Next, create an environment configuration file named .env in the project root directory:

PORT=3000
NODE_ENV=development
JWT_ACCESS_SECRET=c8d9e2a1f4b30198273645e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5
JWT_REFRESH_SECRET=f1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
REDIS_HOST=127.0.0.1
REDIS_PORT=6379

💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.

Implementing the Database and Redis Token Store

We need a persistent store for user accounts and Redis for fast token tracking and rotation state. Create db.js to manage mock user data and Redis operations.

const Redis = require('ioredis');
const bcrypt = require('bcryptjs');

const redis = new Redis({
  host: process.env.REDIS_HOST || '127.0.0.1',
  port: parseInt(process.env.REDIS_PORT || '6379', 10),
});

const usersDatabase = [];

async function initializeDatabase() {
  const hashedPassword = await bcrypt.hash('SecurePassword123!', 10);
  usersDatabase.push({
    id: 'usr_88392104',
    email: 'developer@example.com',
    password: hashedPassword,
  });
}

initializeDatabase();

function findUserByEmail(email) {
  return usersDatabase.find((user) => user.email === email);
}

function findUserById(id) {
  return usersDatabase.find((user) => user.id === id);
}

async function storeRefreshToken(userId, familyId, tokenId, ttlSeconds) {
  const key = `refresh_token:${familyId}:${tokenId}`;
  const data = JSON.stringify({
    userId,
    familyId,
    tokenId,
    isUsed: false,
  });
  await redis.set(key, data, 'EX', ttlSeconds);
  await redis.sadd(`user_families:${userId}`, familyId);
}

async function getRefreshTokenState(familyId, tokenId) {
  const key = `refresh_token:${familyId}:${tokenId}`;
  const data = await redis.get(key);
  if (!data) {
    return null;
  }
  return JSON.parse(data);
}

async function markTokenAsUsed(familyId, tokenId, ttlSeconds) {
  const key = `refresh_token:${familyId}:${tokenId}`;
  const data = await getRefreshTokenState(familyId, tokenId);
  if (data) {
    data.isUsed = true;
    await redis.set(key, JSON.stringify(data), 'EX', ttlSeconds);
  }
}

async function revokeTokenFamily(userId, familyId) {
  const pattern = `refresh_token:${familyId}:*`;
  const stream = redis.scanStream({ match: pattern });
  
  stream.on('data', async (keys) => {
    if (keys.length) {
      const pipeline = redis.pipeline();
      keys.forEach((key) => pipeline.del(key));
      await pipeline.exec();
    }
  });

  await redis.srem(`user_families:${userId}`, familyId);
}

module.exports = {
  findUserByEmail,
  findUserById,
  storeRefreshToken,
  getRefreshTokenState,
  markTokenAsUsed,
  revokeTokenFamily,
  redis,
};

Building Token Rotation Logic and Reuse Detection

Now we build the core cryptographic token utilities and the reuse detection algorithm. Place this logic inside tokens.js.

const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const {
  storeRefreshToken,
  getRefreshTokenState,
  markTokenAsUsed,
  revokeTokenFamily,
} = require('./db');

const REFRESH_TTL_SECONDS = 7 * 24 * 60 * 60;

function generateAccessToken(user) {
  return jwt.sign(
    { userId: user.id, email: user.email },
    process.env.JWT_ACCESS_SECRET,
    { expiresIn: process.env.ACCESS_TOKEN_EXPIRY }
  );
}

function generateTokenIdentifiers() {
  return {
    tokenId: crypto.randomBytes(16).toString('hex'),
    familyId: crypto.randomBytes(16).toString('hex'),
  };
}

async function issueRefreshToken(userId, familyId = null) {
  const tokenId = crypto.randomBytes(16).toString('hex');
  const activeFamilyId = familyId || crypto.randomBytes(16).toString('hex');

  const tokenPayload = {
    userId,
    familyId: activeFamilyId,
    tokenId,
  };

  const refreshToken = jwt.sign(tokenPayload, process.env.JWT_REFRESH_SECRET, {
    expiresIn: process.env.REFRESH_TOKEN_EXPIRY,
  });

  await storeRefreshToken(userId, activeFamilyId, tokenId, REFRESH_TTL_SECONDS);

  return { refreshToken, familyId: activeFamilyId, tokenId };
}

async function rotateRefreshToken(oldRefreshToken) {
  let decoded;
  try {
    decoded = jwt.verify(oldRefreshToken, process.env.JWT_REFRESH_SECRET);
  } catch (error) {
    throw new Error('INVALID_REFRESH_TOKEN');
  }

  const { userId, familyId, tokenId } = decoded;
  const tokenState = await getRefreshTokenState(familyId, tokenId);

  if (!tokenState) {
    await revokeTokenFamily(userId, familyId);
    throw new Error('TOKEN_NOT_FOUND_REVOKED');
  }

  if (tokenState.isUsed) {
    await revokeTokenFamily(userId, familyId);
    throw new Error('TOKEN_REUSE_DETECTED');
  }

  await markTokenAsUsed(familyId, tokenId, REFRESH_TTL_SECONDS);

  const newTokens = await issueRefreshToken(userId, familyId);
  return {
    userId,
    newRefreshToken: newTokens.refreshToken,
  };
}

module.exports = {
  generateAccessToken,
  generateTokenIdentifiers,
  issueRefreshToken,
  rotateRefreshToken,
};

When inspecting system security, tracking these token reuse incidents is vital. By streaming logs into a central management system (as demonstrated in my setup guide for Centralizing Docker Container Logs With Grafana Loki), security teams can fire real time alerts whenever a TOKEN_REUSE_DETECTED status occurs.

Constructing Full Express Authentication Routes

Create the primary web server application in app.js. This module provides full user authorization, token generation, automatic rotation, and revocation endpoints.

require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');

const {
  findUserByEmail,
  findUserById,
  revokeTokenFamily,
} = require('./db');

const {
  generateAccessToken,
  issueRefreshToken,
  rotateRefreshToken,
} = require('./tokens');

const app = express();
app.use(express.json());
app.use(cookieParser());

function authenticateAccessToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];

  if (!token) {
    return res.status(401).json({ error: 'Access token required' });
  }

  jwt.verify(token, process.env.JWT_ACCESS_SECRET, (err, user) => {
    if (err) {
      return res.status(403).json({ error: 'Invalid or expired access token' });
    }
    req.user = user;
    next();
  });
}

app.post('/api/auth/login', async (req, res) => {
  const { email, password } = req.body;

  if (!email || !password) {
    return res.status(400).json({ error: 'Email and password are required' });
  }

  const user = findUserByEmail(email);
  if (!user) {
    return res.status(401).json({ error: 'Invalid user credentials' });
  }

  const isPasswordValid = await bcrypt.compare(password, user.password);
  if (!isPasswordValid) {
    return res.status(401).json({ error: 'Invalid user credentials' });
  }

  const accessToken = generateAccessToken(user);
  const { refreshToken } = await issueRefreshToken(user.id);

  res.cookie('refreshToken', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    path: '/api/auth',
    maxAge: 7 * 24 * 60 * 60 * 1000,
  });

  return res.json({
    message: 'Authentication successful',
    accessToken,
  });
});

app.post('/api/auth/refresh', async (req, res) => {
  const oldRefreshToken = req.cookies.refreshToken;

  if (!oldRefreshToken) {
    return res.status(401).json({ error: 'Refresh token cookie missing' });
  }

  try {
    const { userId, newRefreshToken } = await rotateRefreshToken(oldRefreshToken);
    const user = findUserById(userId);

    if (!user) {
      return res.status(401).json({ error: 'User account no longer exists' });
    }

    const newAccessToken = generateAccessToken(user);

    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production',
      sameSite: 'strict',
      path: '/api/auth',
      maxAge: 7 * 24 * 60 * 60 * 1000,
    });

    return res.json({
      accessToken: newAccessToken,
    });
  } catch (error) {
    res.clearCookie('refreshToken', { path: '/api/auth' });

    if (error.message === 'TOKEN_REUSE_DETECTED') {
      return res.status(403).json({
        error: 'Security alert: Token reuse detected. All user sessions revoked.',
      });
    }

    return res.status(401).json({ error: 'Invalid or revoked refresh token' });
  }
});

app.post('/api/auth/logout', async (req, res) => {
  const refreshToken = req.cookies.refreshToken;

  if (refreshToken) {
    try {
      const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
      await revokeTokenFamily(decoded.userId, decoded.familyId);
    } catch (error) {
      // Token is expired or malformed, clear cookies regardless
    }
  }

  res.clearCookie('refreshToken', { path: '/api/auth' });
  return res.json({ message: 'Logged out successfully' });
});

app.get('/api/protected/profile', authenticateAccessToken, (req, res) => {
  const user = findUserById(req.user.userId);
  return res.json({
    id: user.id,
    email: user.email,
    message: 'Access granted to protected route',
  });
});

const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`Server executing on port ${PORT}`);
});

Production Deployment and Security Hardening

To deploy this setup securely in production, ensure you observe these critical practices:

  1. Secure Cookie Transport: Storing refresh tokens in httpOnly and sameSite: 'strict' cookies protects them against Cross-Site Scripting (XSS) and Cross-Site Request Forgery (CSRF). Set secure: true in production so browsers only send cookies over HTTPS. You can easily manage TLS certificates using my Nginx SSL certificate renewal automation guide.

  2. Distributed Redis Configuration: Ensure Redis is configured with persistence (AOF or RDB) so that active refresh families persist through host reboots.

  3. Short Expiry Times: Set short access token windows (from 5 to 15 minutes max) to limit exposure if an access token leaks.

Getting Started

To implement refresh token rotation across your stack:

  1. Deploy your server infrastructure using high performance hosts like Hetzner VPS, Contabo VPS, or DigitalOcean.
  2. Configure a domain name via Namecheap and secure endpoints with automated SSL certificates.
  3. Integrate automated uptime monitoring and notification routines using n8n Cloud.

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
system online