Token Refresh Strategies

Advanced techniques for maintaining secure authentication sessions

The Challenge of Token Expiration

JWT tokens are designed to expire after a certain period—this is a security feature, not a limitation. However, this creates a challenge: how do we maintain a persistent user session without requiring frequent re-logins?

The Token Expiration Dilemma

Consider this real-world scenario:

  • Short-lived tokens (5-15 minutes): More secure but frustrate users with frequent re-authentication
  • Long-lived tokens (days/weeks): Better user experience but create a significant security risk if stolen

This is analogous to a security badge at a high-security facility. A badge that works for months without renewal is convenient but risky if stolen, while a badge that expires every hour is secure but impractical.

Refresh token strategies solve this dilemma by combining:

Refresh Token Pattern Overview

graph LR A[Client] -->|1. Login with credentials| B[Server] B -->|2. Issues access_token + refresh_token| A A -->|3. Makes API requests with access_token| B B -->|4. Response with data| A A -->|5. access_token expires| A A -->|6. Request new access_token using refresh_token| B B -->|7. Verify refresh_token validity| B B -->|8. Issues new access_token| A A -->|9. Continue making API requests| B subgraph Access Token Lifecycle C[Short-lived access token: 15-60 min] end subgraph Refresh Token Lifecycle D[Long-lived refresh token: days/weeks] end

Real-World Analogy

The refresh token pattern works similar to hotel key cards and room safes:

  • The access token is like your hotel room key card that expires daily
  • The refresh token is like the passport you store in your room safe
  • When your key card expires, you show your passport (refresh token) at the front desk to get a new key card without having to check in again
  • If someone steals your key card, they only have access for a limited time
  • If your passport is stolen, you can report it, and the hotel can invalidate it immediately

Basic Refresh Token Implementation

Let's review the core implementation of the refresh token pattern:

sequenceDiagram participant Client participant Server participant Database Note over Client,Server: Initial Authentication Client->>Server: Login with credentials Server->>Database: Verify credentials Server->>Server: Generate access_token (short-lived) Server->>Server: Generate refresh_token (long-lived) Server->>Database: Store refresh_token Server->>Client: Return access_token + refresh_token Note over Client,Server: Normal API Usage Client->>Server: Request with access_token Server->>Server: Verify access_token Server->>Client: Return requested data Note over Client,Server: Token Refresh Process Client->>Server: Request with expired access_token Server->>Client: 401 Unauthorized Client->>Server: Request new access_token with refresh_token Server->>Database: Verify refresh_token exists and is valid Server->>Server: Generate new access_token Server->>Client: Return new access_token

Basic Server-Side Refresh Endpoint

// routes/authRoutes.js
router.post('/refresh-token', async (req, res) => {
  try {
    const { refreshToken } = req.body;
    
    if (!refreshToken) {
      return res.status(401).json({ 
        success: false, 
        message: 'Refresh token is required' 
      });
    }
    
    // Verify the refresh token
    let payload;
    try {
      // Verify signature and expiration
      payload = jwt.verify(refreshToken, process.env.JWT_SECRET);
    } catch (err) {
      return res.status(403).json({ 
        success: false, 
        message: 'Invalid refresh token' 
      });
    }
    
    // Check if token exists in database
    const storedToken = await Token.findOne({ token: refreshToken });
    
    if (!storedToken) {
      return res.status(403).json({ 
        success: false, 
        message: 'Refresh token not found or revoked' 
      });
    }
    
    // Create new access token
    const accessToken = jwt.sign(
      { sub: payload.sub },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' }
    );
    
    // Return new access token
    res.json({
      success: true,
      accessToken
    });
  } catch (error) {
    res.status(500).json({ 
      success: false, 
      message: 'Error refreshing token',
      error: error.message
    });
  }
});

Client-Side Refresh Implementation

// Client-side refresh logic using axios
import axios from 'axios';

const API_URL = 'http://localhost:3000/api';

// Axios instance
const api = axios.create({
  baseURL: API_URL,
  headers: {
    'Content-Type': 'application/json'
  }
});

// Add request interceptor to add auth header
api.interceptors.request.use(
  (config) => {
    const accessToken = localStorage.getItem('accessToken');
    
    if (accessToken) {
      config.headers['Authorization'] = `Bearer ${accessToken}`;
    }
    
    return config;
  },
  (error) => Promise.reject(error)
);

// Add response interceptor to handle token expiration
api.interceptors.response.use(
  (response) => response,
  async (error) => {
    const originalRequest = error.config;
    
    // If error is not 401 or request has already been retried, reject
    if (error.response.status !== 401 || originalRequest._retry) {
      return Promise.reject(error);
    }
    
    originalRequest._retry = true;
    
    try {
      // Get refresh token from storage
      const refreshToken = localStorage.getItem('refreshToken');
      
      if (!refreshToken) {
        // No refresh token, redirect to login
        window.location.href = '/login';
        return Promise.reject(error);
      }
      
      // Request new access token
      const response = await axios.post(`${API_URL}/auth/refresh-token`, {
        refreshToken
      });
      
      const { accessToken } = response.data;
      
      // Store the new access token
      localStorage.setItem('accessToken', accessToken);
      
      // Update auth header
      originalRequest.headers['Authorization'] = `Bearer ${accessToken}`;
      
      // Retry the original request
      return axios(originalRequest);
    } catch (refreshError) {
      // Refresh token is invalid, clear tokens and redirect to login
      localStorage.removeItem('accessToken');
      localStorage.removeItem('refreshToken');
      window.location.href = '/login';
      
      return Promise.reject(refreshError);
    }
  }
);

Advanced Refresh Token Strategies

Beyond the basic implementation, several advanced strategies enhance security and user experience:

Strategy 1: Refresh Token Rotation

With refresh token rotation, a new refresh token is issued each time the access token is refreshed. This limits the window of opportunity if a refresh token is stolen.

sequenceDiagram participant Client participant Server participant Database Client->>Server: Refresh with token A Server->>Database: Verify token A is valid Server->>Server: Generate new access token Server->>Server: Generate new refresh token B Server->>Database: Store token B, invalidate token A Server->>Client: Return new access token + refresh token B Note over Client,Server: Next refresh cycle Client->>Server: Refresh with token B Server->>Database: Verify token B is valid Server->>Server: Generate new access token Server->>Server: Generate new refresh token C Server->>Database: Store token C, invalidate token B Server->>Client: Return new access token + refresh token C
// Enhanced refresh token endpoint with rotation
router.post('/refresh-token', async (req, res) => {
  try {
    const { refreshToken } = req.body;
    
    if (!refreshToken) {
      return res.status(401).json({ message: 'Refresh token is required' });
    }
    
    // Verify the refresh token
    let payload;
    try {
      payload = jwt.verify(refreshToken, process.env.JWT_SECRET);
    } catch (err) {
      return res.status(403).json({ message: 'Invalid refresh token' });
    }
    
    // Check if token exists in database
    const storedToken = await Token.findOne({ token: refreshToken });
    
    if (!storedToken) {
      return res.status(403).json({ message: 'Refresh token not found or revoked' });
    }
    
    // Create new tokens
    const accessToken = jwt.sign(
      { sub: payload.sub },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' }
    );
    
    // Create new refresh token (rotation)
    const newRefreshToken = jwt.sign(
      { sub: payload.sub },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_REFRESH_EXPIRATION || '7d' }
    );
    
    // Calculate expiry date for database
    const refreshExpiry = new Date();
    refreshExpiry.setDate(
      refreshExpiry.getDate() + 
      parseInt(process.env.JWT_REFRESH_EXPIRATION.replace('d', '') || 7)
    );
    
    // Update token in database (invalidate old, store new)
    await Token.findByIdAndRemove(storedToken._id);
    
    await Token.create({
      userId: payload.sub,
      token: newRefreshToken,
      expiryDate: refreshExpiry
    });
    
    // Return new tokens
    res.json({
      success: true,
      accessToken,
      refreshToken: newRefreshToken
    });
  } catch (error) {
    res.status(500).json({ 
      success: false, 
      message: 'Error refreshing token',
      error: error.message 
    });
  }
});

Benefits of Token Rotation

  • Limited exposure window: A stolen refresh token is only valid until the next refresh operation
  • Automatic invalidation: Previous tokens are automatically invalidated, helping prevent replay attacks
  • Detection capabilities: If an attacker uses a stolen token after the legitimate user has refreshed, the server can detect this suspicious activity

Strategy 2: Refresh Token Family

Refresh token families track the lineage of rotated tokens, enabling detection of token theft and automatic invalidation of compromised token chains.

// Enhanced Token model with family tracking
const tokenSchema = new mongoose.Schema({
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  token: {
    type: String,
    required: true
  },
  expiryDate: {
    type: Date,
    required: true
  },
  family: {
    type: String,
    required: true
  },
  generation: {
    type: Number,
    required: true,
    default: 0
  },
  used: {
    type: Boolean,
    default: false
  },
  createdAt: {
    type: Date,
    default: Date.now,
    expires: '30d' // Document TTL
  }
});

// Enhanced refresh endpoint with family tracking
router.post('/refresh-token', async (req, res) => {
  try {
    const { refreshToken } = req.body;
    
    // ... verify JWT signature as before ...
    
    // Get stored token
    const storedToken = await Token.findOne({ token: refreshToken });
    
    if (!storedToken) {
      return res.status(403).json({ message: 'Refresh token not found' });
    }
    
    // Check if token has been used (potential token theft)
    if (storedToken.used) {
      // Potential token theft detected! Invalidate all tokens in this family
      await Token.deleteMany({ family: storedToken.family });
      
      return res.status(403).json({ 
        success: false,
        message: 'Token theft detected! Please login again' 
      });
    }
    
    // Mark current token as used
    storedToken.used = true;
    await storedToken.save();
    
    // Generate new tokens
    const accessToken = jwt.sign(
      { sub: payload.sub },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' }
    );
    
    const newRefreshToken = jwt.sign(
      { sub: payload.sub },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_REFRESH_EXPIRATION || '7d' }
    );
    
    // Store new refresh token with same family but next generation
    const refreshExpiry = new Date();
    refreshExpiry.setDate(
      refreshExpiry.getDate() + 
      parseInt(process.env.JWT_REFRESH_EXPIRATION.replace('d', '') || 7)
    );
    
    await Token.create({
      userId: storedToken.userId,
      token: newRefreshToken,
      expiryDate: refreshExpiry,
      family: storedToken.family,
      generation: storedToken.generation + 1
    });
    
    res.json({
      success: true,
      accessToken,
      refreshToken: newRefreshToken
    });
  } catch (error) {
    res.status(500).json({ 
      success: false,
      message: 'Error refreshing token',
      error: error.message 
    });
  }
});

How Token Families Detect Theft

Consider this scenario:

  1. User logs in and receives refresh token A (Family: F123, Generation: 0)
  2. Attacker somehow steals refresh token A
  3. User refreshes first and receives token B (Family: F123, Generation: 1)
  4. Token A is marked as "used" but not deleted
  5. Attacker tries to use stolen token A
  6. Server sees token A is already marked "used"
  7. Server invalidates ALL tokens in family F123
  8. Both attacker and legitimate user need to re-authenticate

This approach provides an excellent balance between security and user experience, as legitimate users will only be logged out if token theft is detected.

Strategy 3: Sliding Window Refresh

With sliding window refresh, the refresh token's expiration is extended each time it's used, as long as it's used within a valid timeframe.

// Sliding window refresh implementation
router.post('/refresh-token', async (req, res) => {
  try {
    const { refreshToken } = req.body;
    
    // ... verify JWT signature as before ...
    
    // Get stored token
    const storedToken = await Token.findOne({ token: refreshToken });
    
    if (!storedToken) {
      return res.status(403).json({ message: 'Refresh token not found' });
    }
    
    // Generate new access token
    const accessToken = jwt.sign(
      { sub: payload.sub },
      process.env.JWT_SECRET,
      { expiresIn: process.env.JWT_ACCESS_EXPIRATION || '15m' }
    );
    
    // Extend refresh token expiration (sliding window)
    const newExpiryDate = new Date();
    newExpiryDate.setDate(
      newExpiryDate.getDate() + 
      parseInt(process.env.JWT_REFRESH_EXPIRATION.replace('d', '') || 7)
    );
    
    // Only extend if new expiry would be later than current
    if (newExpiryDate > storedToken.expiryDate) {
      storedToken.expiryDate = newExpiryDate;
      await storedToken.save();
    }
    
    res.json({
      success: true,
      accessToken
    });
  } catch (error) {
    res.status(500).json({ 
      success: false, 
      message: 'Error refreshing token',
      error: error.message 
    });
  }
});

Benefits of Sliding Window Refresh

The sliding window approach provides:

  • Extended sessions for active users: Users who regularly use your app stay logged in longer
  • Automatic timeout for inactive users: If users don't use the app for the refresh token lifetime, they're logged out
  • Improved user experience: Reduces the need for re-authentication for regular users

This is similar to how video streaming services keep you logged in as long as you watch something every few days, but log you out after extended inactivity.

Refresh Token Storage

Proper storage of refresh tokens is crucial for security:

Server-side Storage

// Enhanced token model with hashed token storage
const crypto = require('crypto');

const tokenSchema = new mongoose.Schema({
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  // Store hash of token instead of raw token
  tokenHash: {
    type: String,
    required: true
  },
  expiryDate: {
    type: Date,
    required: true
  },
  createdAt: {
    type: Date,
    default: Date.now,
    expires: '30d'
  }
});

// Helper function to hash tokens
function hashToken(token) {
  return crypto
    .createHash('sha256')
    .update(token)
    .digest('hex');
}

// Modified token creation
async function createRefreshToken(userId, token) {
  const expiryDate = new Date();
  expiryDate.setDate(
    expiryDate.getDate() + 
    parseInt(process.env.JWT_REFRESH_EXPIRATION.replace('d', '') || 7)
  );
  
  await Token.create({
    userId,
    tokenHash: hashToken(token),
    expiryDate
  });
}

// Modified token lookup
async function findRefreshToken(token) {
  return Token.findOne({ tokenHash: hashToken(token) });
}

Client-side Storage

Storage Method Security Level Best For Risks
HttpOnly Cookie High Refresh tokens CSRF attacks (mitigable)
localStorage Low Development only XSS attacks
In-memory (JavaScript variable) Medium Access tokens Lost on page refresh

Best Practice: Cookie-based Refresh Tokens

The most secure approach for web applications is:

  • Access token: Stored in memory (JavaScript variable or state)
  • Refresh token: Stored in HttpOnly, Secure, SameSite=Strict cookie

This combination provides excellent security since:

  • Access tokens are short-lived, minimizing damage if stolen
  • Refresh tokens in HttpOnly cookies cannot be accessed by JavaScript (XSS protection)
  • SameSite=Strict cookies provides CSRF protection
// Setting refresh token in HttpOnly cookie
router.post('/login', async (req, res) => {
  try {
    // ... authentication logic ...
    
    // Generate tokens
    const accessToken = jwt.sign(
      { sub: user._id },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );
    
    const refreshToken = jwt.sign(
      { sub: user._id },
      process.env.JWT_SECRET,
      { expiresIn: '7d' }
    );
    
    // Save refresh token in database (typically as a hash)
    await createRefreshToken(user._id, refreshToken);
    
    // Set refresh token as HttpOnly cookie
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: process.env.NODE_ENV === 'production', // HTTPS only in production
      sameSite: 'strict',
      maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in milliseconds
      path: '/api/auth/refresh-token' // Restrict cookie to refresh endpoint
    });
    
    // Return access token in response body
    res.json({
      success: true,
      accessToken,
      user: {
        id: user._id,
        email: user.email,
        name: user.name
      }
    });
  } catch (error) {
    res.status(500).json({ 
      success: false, 
      message: 'Login failed',
      error: error.message 
    });
  }
});

// Refresh endpoint that uses the cookie
router.post('/refresh-token', async (req, res) => {
  try {
    // Get refresh token from cookie instead of request body
    const refreshToken = req.cookies.refreshToken;
    
    if (!refreshToken) {
      return res.status(401).json({ message: 'Refresh token required' });
    }
    
    // ... verify and validate token as before ...
    
    // Generate new access token
    const accessToken = jwt.sign(
      { sub: payload.sub },
      process.env.JWT_SECRET,
      { expiresIn: '15m' }
    );
    
    // Return new access token
    res.json({
      success: true,
      accessToken
    });
  } catch (error) {
    res.status(500).json({ 
      success: false, 
      message: 'Token refresh failed',
      error: error.message 
    });
  }
});

Cross-Site Request Forgery (CSRF) Protection

When using cookies for refresh tokens, CSRF protection is essential:

// Install CSRF protection
npm install csurf

// server.js or routes file
const csrf = require('csurf');
const cookieParser = require('cookie-parser');

// Setup CSRF protection
const csrfProtection = csrf({ 
  cookie: { 
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict'
  } 
});

// Apply middleware
app.use(cookieParser());

// CSRF protection for token refresh
router.post('/refresh-token', csrfProtection, async (req, res) => {
  try {
    // Get token from cookie
    const refreshToken = req.cookies.refreshToken;
    
    // ... rest of the refresh logic ...
    
    res.json({
      success: true,
      accessToken,
      // Include CSRF token in response
      csrfToken: req.csrfToken()
    });
  } catch (error) {
    res.status(500).json({ 
      success: false, 
      message: 'Token refresh failed',
      error: error.message 
    });
  }
});

// Client-side implementation
// Include CSRF token in subsequent requests
fetch('/api/resource', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken, // From previous response
    'Authorization': `Bearer ${accessToken}`
  },
  credentials: 'include', // Important for cookies
  body: JSON.stringify(data)
});

SameSite Cookie Attribute

Modern browsers support the SameSite cookie attribute, which can significantly mitigate CSRF attacks:

  • SameSite=Strict: Cookie is only sent in requests from the same site (strongest protection)
  • SameSite=Lax: Cookie is sent in same-site requests and top-level navigations
  • SameSite=None: Cookie is sent in all contexts (requires Secure attribute)

For refresh tokens, SameSite=Strict is recommended as they should only be used by your own application.

Mobile and Native App Considerations

Mobile and native applications have different security considerations for refresh tokens:

Secure Storage Options

  • iOS: Keychain Services
  • Android: EncryptedSharedPreferences, Keystore
  • React Native: react-native-keychain, expo-secure-store
  • Electron: electron-store with encryption
// React Native example with react-native-keychain
import * as Keychain from 'react-native-keychain';

// Storing tokens securely
const storeTokens = async (accessToken, refreshToken) => {
  try {
    // Store access token
    await Keychain.setGenericPassword(
      'accessToken',
      accessToken,
      {
        service: 'accessToken',
        accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED
      }
    );
    
    // Store refresh token
    await Keychain.setGenericPassword(
      'refreshToken',
      refreshToken,
      {
        service: 'refreshToken',
        accessible: Keychain.ACCESSIBLE.WHEN_UNLOCKED
      }
    );
    
    return true;
  } catch (error) {
    console.error('Error storing tokens:', error);
    return false;
  }
};

// Retrieving tokens
const getAccessToken = async () => {
  try {
    const credentials = await Keychain.getGenericPassword({
      service: 'accessToken'
    });
    
    if (credentials) {
      return credentials.password;
    }
    return null;
  } catch (error) {
    console.error('Error getting access token:', error);
    return null;
  }
};

const getRefreshToken = async () => {
  try {
    const credentials = await Keychain.getGenericPassword({
      service: 'refreshToken'
    });
    
    if (credentials) {
      return credentials.password;
    }
    return null;
  } catch (error) {
    console.error('Error getting refresh token:', error);
    return null;
  }
};

Device Authentication

For enhanced security in mobile apps, you can bind refresh tokens to specific devices:

// Enhanced token model with device information
const tokenSchema = new mongoose.Schema({
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true
  },
  tokenHash: {
    type: String,
    required: true
  },
  expiryDate: {
    type: Date,
    required: true
  },
  // Device information
  deviceInfo: {
    deviceId: String,
    deviceName: String,
    platform: String,
    appVersion: String,
    lastIp: String,
    lastUsed: Date
  },
  createdAt: {
    type: Date,
    default: Date.now,
    expires: '30d'
  }
});

// Server endpoint with device binding
router.post('/login', async (req, res) => {
  try {
    // ... authentication logic ...
    
    // Get device information from request
    const { deviceId, deviceName, platform, appVersion } = req.body;
    
    // Generate tokens
    const accessToken = jwt.sign({ /* ... */ }, process.env.JWT_SECRET, { expiresIn: '15m' });
    const refreshToken = jwt.sign({ /* ... */ }, process.env.JWT_SECRET, { expiresIn: '30d' });
    
    // Store with device information
    await Token.create({
      userId: user._id,
      tokenHash: hashToken(refreshToken),
      expiryDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
      deviceInfo: {
        deviceId,
        deviceName,
        platform,
        appVersion,
        lastIp: req.ip,
        lastUsed: new Date()
      }
    });
    
    res.json({
      success: true,
      accessToken,
      refreshToken
    });
  } catch (error) {
    res.status(500).json({ 
      success: false, 
      message: 'Login failed',
      error: error.message 
    });
  }
});

Testing Refresh Token Implementations

Proper testing is essential for ensuring your refresh token implementation works correctly:

// jest test example
const request = require('supertest');
const jwt = require('jsonwebtoken');
const app = require('../app');
const User = require('../models/User');
const Token = require('../models/Token');
const { connect, disconnect } = require('../config/database');

beforeAll(async () => {
  await connect();
});

afterAll(async () => {
  await disconnect();
});

describe('Refresh Token Flow', () => {
  let accessToken, refreshToken, userId;
  
  beforeEach(async () => {
    // Create test user
    await User.deleteMany({});
    await Token.deleteMany({});
    
    const user = await User.create({
      email: 'test@example.com',
      password: 'password123',
      username: 'testuser'
    });
    
    userId = user._id;
    
    // Login to get tokens
    const res = await request(app)
      .post('/api/auth/login')
      .send({ email: 'test@example.com', password: 'password123' });
    
    accessToken = res.body.accessToken;
    refreshToken = res.body.refreshToken;
  });
  
  test('Should access protected route with valid access token', async () => {
    const res = await request(app)
      .get('/api/users/profile')
      .set('Authorization', `Bearer ${accessToken}`);
    
    expect(res.statusCode).toBe(200);
    expect(res.body.success).toBe(true);
  });
  
  test('Should refresh token when access token expires', async () => {
    // Create expired access token
    const expiredToken = jwt.sign(
      { sub: userId },
      process.env.JWT_SECRET,
      { expiresIn: '0s' } // Immediately expired
    );
    
    // Try to access protected route with expired token
    const failedRes = await request(app)
      .get('/api/users/profile')
      .set('Authorization', `Bearer ${expiredToken}`);
    
    expect(failedRes.statusCode).toBe(401);
    
    // Refresh the token
    const refreshRes = await request(app)
      .post('/api/auth/refresh-token')
      .send({ refreshToken });
    
    expect(refreshRes.statusCode).toBe(200);
    expect(refreshRes.body.accessToken).toBeDefined();
    
    // Access protected route with new token
    const newAccessToken = refreshRes.body.accessToken;
    const successRes = await request(app)
      .get('/api/users/profile')
      .set('Authorization', `Bearer ${newAccessToken}`);
    
    expect(successRes.statusCode).toBe(200);
  });
  
  test('Should fail with invalid refresh token', async () => {
    const res = await request(app)
      .post('/api/auth/refresh-token')
      .send({ refreshToken: 'invalid-token' });
    
    expect(res.statusCode).toBe(403);
  });
  
  test('Should detect token reuse in family-based implementation', async () => {
    // Refresh once to get a new token
    const refresh1 = await request(app)
      .post('/api/auth/refresh-token')
      .send({ refreshToken });
    
    const newRefreshToken = refresh1.body.refreshToken;
    
    // Try to use the original token again (simulating theft)
    const reusedTokenRes = await request(app)
      .post('/api/auth/refresh-token')
      .send({ refreshToken });
    
    expect(reusedTokenRes.statusCode).toBe(403);
    expect(reusedTokenRes.body.message).toContain('token theft');
    
    // Verify that all tokens in family are invalidated
    const invalidatedFamilyRes = await request(app)
      .post('/api/auth/refresh-token')
      .send({ refreshToken: newRefreshToken });
    
    expect(invalidatedFamilyRes.statusCode).toBe(403);
  });
});

Handling Edge Cases

A robust refresh token implementation must handle various edge cases:

Common Edge Cases

  • Concurrent refresh requests: Multiple tabs refreshing tokens simultaneously
  • Network interruptions: Client loses connection during refresh process
  • Clock skew: Server and client have different time settings
  • Client-server version mismatch: Token format changes in a new API version
  • Max sessions per user: Limiting the number of active sessions

Managing Multiple Active Sessions

// Example: Limiting active sessions per user
const MAX_SESSIONS_PER_USER = 5;

// During login
router.post('/login', async (req, res) => {
  try {
    // ... authentication logic ...
    
    // Check number of active sessions
    const activeTokenCount = await Token.countDocuments({ 
      userId: user._id
    });
    
    // If max sessions reached, remove oldest session
    if (activeTokenCount >= MAX_SESSIONS_PER_USER) {
      // Find and remove oldest token
      const oldestToken = await Token.findOne({ 
        userId: user._id 
      }).sort({ createdAt: 1 });
      
      if (oldestToken) {
        await Token.findByIdAndRemove(oldestToken._id);
      }
    }
    
    // Generate and store new token
    // ... rest of login logic ...
  } catch (error) {
    res.status(500).json({ 
      success: false, 
      message: 'Login failed',
      error: error.message 
    });
  }
});

// API endpoint to view and manage active sessions
router.get('/active-sessions', verifyToken, async (req, res) => {
  try {
    const sessions = await Token.find({ 
      userId: req.userId 
    }).select('deviceInfo createdAt expiryDate');
    
    res.json({
      success: true,
      sessions: sessions.map(session => ({
        id: session._id,
        device: session.deviceInfo.deviceName,
        platform: session.deviceInfo.platform,
        lastUsed: session.deviceInfo.lastUsed,
        createdAt: session.createdAt,
        expiresAt: session.expiryDate
      }))
    });
  } catch (error) {
    res.status(500).json({ 
      success: false, 
      message: 'Error retrieving sessions',
      error: error.message 
    });
  }
});

// API endpoint to revoke a specific session
router.post('/revoke-session/:sessionId', verifyToken, async (req, res) => {
  try {
    const { sessionId } = req.params;
    
    // Find the session and verify it belongs to current user
    const session = await Token.findOne({
      _id: sessionId,
      userId: req.userId
    });
    
    if (!session) {
      return res.status(404).json({
        success: false,
        message: 'Session not found'
      });
    }
    
    // Remove the session
    await Token.findByIdAndRemove(sessionId);
    
    res.json({
      success: true,
      message: 'Session revoked successfully'
    });
  } catch (error) {
    res.status(500).json({ 
      success: false, 
      message: 'Error revoking session',
      error: error.message 
    });
  }
});

Production-Ready Implementation Checklist

Before deploying your refresh token strategy to production, ensure it meets these requirements:

Security Checklist

  • HTTPS: Ensure all authentication endpoints use HTTPS
  • Secure token storage: HttpOnly cookies or secure storage for refresh tokens
  • Token expiration: Short-lived access tokens (15-60 min), reasonable refresh token expiration (1-4 weeks)
  • Token rotation or families: Implement a theft detection mechanism
  • Refresh token hashing: Store hashed tokens rather than raw tokens
  • CSRF protection: Protect cookie-based implementations
  • Device tracking: Associate tokens with device information
  • Rate limiting: Limit refresh attempts to prevent brute force attacks

User Experience Checklist

  • Transparent refreshes: Users shouldn't notice token refreshes
  • Multi-tab support: Handle concurrent refresh requests
  • Session management: Allow users to view and manage active sessions
  • "Remember me" option: Vary token expiration based on user preference
  • Graceful degradation: Handle refresh failures without breaking the UI

Operational Checklist

  • Monitoring: Track token usage, refresh rates, and failures
  • Token cleanup: Implement TTL or periodic cleanup of expired tokens
  • Database indexing: Ensure token lookups are efficient
  • Error handling: Handle all edge cases gracefully
  • Logging: Log suspicious activities (theft detection, unusual refresh patterns)

Practice Activities

Activity 1: Implement Refresh Token Rotation

Enhance your JWT authentication system with refresh token rotation:

  1. Modify your refresh token endpoint to issue a new refresh token with each refresh
  2. Update the database schema to track token usage
  3. Create a system to detect and handle token reuse
  4. Test the implementation with multiple scenarios (normal refresh, token reuse)

Activity 2: Cookie-based Refresh Implementation

Implement a secure cookie-based refresh token system:

  1. Modify your server to store refresh tokens in HttpOnly cookies
  2. Add proper cookie settings (Secure, SameSite=Strict)
  3. Implement CSRF protection for cookie-based endpoints
  4. Test cross-site scenarios to verify CSRF protection

Activity 3: Session Management Interface

Create a user-facing session management interface:

  1. Create an API endpoint to list all active sessions for a user
  2. Implement the ability to revoke specific sessions
  3. Add a "Log out everywhere" feature
  4. Create a React component to display and manage sessions
  5. Include device information and last activity time for each session

Additional Resources

Summary

In our next session, we'll explore session-based authentication as an alternative authentication method.