X Xerobit

JWT Refresh Token — Implement Sliding Sessions with Refresh Tokens

Refresh tokens extend JWT sessions without requiring re-login. Learn how to implement a refresh token flow with short-lived access tokens, secure httpOnly refresh token...

Mian Ali Khalid · · 5 min read
Use the tool
JWT Decoder
Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.
Open JWT Decoder →

Refresh tokens let users stay logged in without storing long-lived credentials in the browser. The pattern: short-lived access tokens (15 minutes) + long-lived refresh tokens (30 days, stored in httpOnly cookies).

Decode and inspect JWT tokens with the JWT Decoder.

The refresh token flow

1. Login → server returns:
   - access_token (JWT, 15min expiry, in response body)
   - refresh_token (opaque token, 30 day expiry, in httpOnly cookie)

2. Client uses access_token for API requests

3. access_token expires → client calls /auth/refresh endpoint

4. Server validates refresh_token cookie → issues new access_token
   (and optionally a new refresh_token — token rotation)

5. Logout → server revokes refresh_token in database

Node.js implementation

import jwt from 'jsonwebtoken';
import crypto from 'crypto';
import { db } from './db.js';

const ACCESS_SECRET = process.env.ACCESS_TOKEN_SECRET;
const REFRESH_SECRET = process.env.REFRESH_TOKEN_SECRET;
const ACCESS_TTL = '15m';
const REFRESH_TTL_DAYS = 30;

// Generate token pair:
function generateTokens(userId) {
  const accessToken = jwt.sign(
    { sub: userId, type: 'access' },
    ACCESS_SECRET,
    { expiresIn: ACCESS_TTL }
  );

  const refreshToken = crypto.randomBytes(40).toString('hex'); // Opaque token
  
  return { accessToken, refreshToken };
}

// Login route:
app.post('/auth/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await authenticateUser(email, password);
  
  if (!user) return res.status(401).json({ error: 'Invalid credentials' });

  const { accessToken, refreshToken } = generateTokens(user.id);
  
  // Store refresh token hash in database:
  const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
  const expiresAt = new Date(Date.now() + REFRESH_TTL_DAYS * 86400000);
  
  await db.refreshTokens.create({
    userId: user.id,
    tokenHash,
    expiresAt,
    createdAt: new Date(),
  });

  // Set refresh token as httpOnly cookie:
  res.cookie('refresh_token', refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: REFRESH_TTL_DAYS * 86400000,
    path: '/auth',  // Only sent to /auth routes
  });

  res.json({ accessToken });
});

Refresh endpoint with token rotation

app.post('/auth/refresh', async (req, res) => {
  const refreshToken = req.cookies.refresh_token;
  
  if (!refreshToken) {
    return res.status(401).json({ error: 'No refresh token' });
  }

  // Look up token in database:
  const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
  const stored = await db.refreshTokens.findOne({ tokenHash });

  if (!stored) {
    // Token not found — possible reuse attack:
    // Revoke ALL tokens for this user (if we can identify them):
    return res.status(401).json({ error: 'Invalid refresh token' });
  }

  if (stored.expiresAt < new Date()) {
    await db.refreshTokens.delete({ id: stored.id });
    return res.status(401).json({ error: 'Refresh token expired' });
  }

  if (stored.revokedAt) {
    // Token was already used — possible reuse attack
    await db.refreshTokens.deleteAll({ userId: stored.userId });
    return res.status(401).json({ error: 'Refresh token reused' });
  }

  // Token rotation: invalidate old, issue new:
  const { accessToken, refreshToken: newRefreshToken } = generateTokens(stored.userId);
  
  // Mark old token as used (don't delete — allows reuse detection):
  await db.refreshTokens.update(
    { id: stored.id },
    { revokedAt: new Date() }
  );
  
  // Store new refresh token:
  const newHash = crypto.createHash('sha256').update(newRefreshToken).digest('hex');
  await db.refreshTokens.create({
    userId: stored.userId,
    tokenHash: newHash,
    expiresAt: new Date(Date.now() + REFRESH_TTL_DAYS * 86400000),
  });

  res.cookie('refresh_token', newRefreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === 'production',
    sameSite: 'strict',
    maxAge: REFRESH_TTL_DAYS * 86400000,
    path: '/auth',
  });

  res.json({ accessToken });
});

Logout and token revocation

app.post('/auth/logout', async (req, res) => {
  const refreshToken = req.cookies.refresh_token;
  
  if (refreshToken) {
    const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
    // Revoke the token:
    await db.refreshTokens.update(
      { tokenHash },
      { revokedAt: new Date() }
    );
  }

  // Clear the cookie:
  res.clearCookie('refresh_token', { path: '/auth' });
  res.json({ message: 'Logged out' });
});

// Revoke all sessions (e.g., after password change):
app.post('/auth/logout-all', authenticateMiddleware, async (req, res) => {
  await db.refreshTokens.deleteAll({ userId: req.user.id });
  res.clearCookie('refresh_token', { path: '/auth' });
  res.json({ message: 'All sessions logged out' });
});

Client-side refresh (React/fetch)

let isRefreshing = false;
let refreshQueue = [];

async function authFetch(url, options = {}) {
  const token = localStorage.getItem('access_token');
  
  const res = await fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${token}`,
    },
    credentials: 'include',  // Send refresh token cookie
  });

  if (res.status !== 401) return res;

  // Access token expired — refresh:
  if (!isRefreshing) {
    isRefreshing = true;
    
    const refreshRes = await fetch('/auth/refresh', {
      method: 'POST',
      credentials: 'include',
    });

    isRefreshing = false;

    if (!refreshRes.ok) {
      // Refresh failed — redirect to login
      refreshQueue.forEach(reject => reject());
      refreshQueue = [];
      window.location.href = '/login';
      return res;
    }

    const { accessToken } = await refreshRes.json();
    localStorage.setItem('access_token', accessToken);
    
    // Retry queued requests:
    refreshQueue.forEach(resolve => resolve(accessToken));
    refreshQueue = [];
  }

  // Retry the original request with new token:
  const newToken = localStorage.getItem('access_token');
  return fetch(url, {
    ...options,
    headers: {
      ...options.headers,
      Authorization: `Bearer ${newToken}`,
    },
    credentials: 'include',
  });
}

Related posts

Related tool

JWT Decoder

Decode and inspect JSON Web Tokens. Local-only — tokens never leave your browser.

Written by Mian Ali Khalid. Part of the Encoding & Crypto pillar.