How to Implement JWT Refresh Tokens in Node.js: A Complete Guide

How to Implement JWT Refresh Tokens in Node.js: A Complete Guide

by | Apr 6, 2026 | Uncategorized | 0 comments

Why JWT Refresh Tokens Matter in Node.js Applications

If you are building a Node.js Express application that uses JSON Web Tokens (JWT) for authentication, you have probably run into a common dilemma: short-lived access tokens expire too quickly for a good user experience, but long-lived tokens are a security risk.

The solution? JWT refresh tokens. A refresh token is a long-lived credential that allows users to obtain a new access token without logging in again. When paired with a token rotation strategy, refresh tokens give you the best of both worlds: strong security and seamless sessions.

In this complete guide, we will walk through how to implement JWT refresh tokens in Node.js step by step. You will learn:

  • The difference between access tokens and refresh tokens
  • How to generate, store, and validate both token types
  • How to implement secure refresh token rotation
  • How to handle token expiration gracefully
  • How to revoke tokens on logout

By the end, you will have a production-ready authentication flow you can drop into any Express API.

Access Token vs Refresh Token: What Is the Difference?

Before we write any code, let us clarify the two token types and their roles.

Feature Access Token Refresh Token
Purpose Authorize API requests Obtain a new access token
Lifetime Short (5 to 15 minutes) Long (days or weeks)
Where it is sent Authorization header (Bearer) HTTP-only cookie or secure request body
Stored on server? No (stateless) Yes (database or in-memory store)
Revocable? Not easily (must wait for expiration) Yes (delete from server store)

The key takeaway: access tokens are disposable keys that open doors, while refresh tokens are the master key that issues new disposable keys. Keeping access tokens short-lived limits the damage if one is stolen. Storing refresh tokens on the server means you can revoke them at any time.

Project Setup

Let us start with a fresh Node.js Express project. Make sure you have Node.js 18 or later installed.

Step 1: Initialize the Project

mkdir jwt-refresh-demo
cd jwt-refresh-demo
npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser dotenv uuid
npm install --save-dev nodemon

Here is what each package does:

  • express – Web framework
  • jsonwebtoken – Sign and verify JWTs
  • bcryptjs – Hash passwords
  • cookie-parser – Parse HTTP-only cookies
  • dotenv – Load environment variables
  • uuid – Generate unique token families for rotation detection

Step 2: Environment Variables

Create a .env file in your project root:

ACCESS_TOKEN_SECRET=your_access_token_secret_here
REFRESH_TOKEN_SECRET=your_refresh_token_secret_here
ACCESS_TOKEN_EXPIRY=15m
REFRESH_TOKEN_EXPIRY=7d
PORT=3000

Important: In production, use long, randomly generated secrets (at least 64 characters). Never commit your .env file to version control.

Step 3: Basic Express Server

Create server.js:

require('dotenv').config();
const express = require('express');
const cookieParser = require('cookie-parser');

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

const authRoutes = require('./routes/auth');
app.use('/api/auth', authRoutes);

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

Understanding Refresh Token Rotation

Before we implement the token logic, it is essential to understand refresh token rotation, which is the strategy we will use for maximum security.

How Token Rotation Works

  1. The user logs in and receives an access token and a refresh token (RT1).
  2. When the access token expires, the client sends RT1 to the refresh endpoint.
  3. The server verifies RT1, invalidates it, and issues a brand-new access token and a new refresh token (RT2).
  4. If an attacker tries to reuse RT1 after the legitimate user already used it, the server detects reuse and revokes the entire token family, forcing all sessions to re-authenticate.

This is significantly more secure than reusing the same refresh token over and over. Even if a refresh token is stolen, the window of exploitation is extremely small.

Step-by-Step Implementation

Step 4: In-Memory User and Token Store

For this tutorial, we will use simple in-memory arrays. In production, replace these with a proper database such as PostgreSQL, MongoDB, or Redis.

Create store.js:

// Simulated user database
const users = [];

// Refresh token store
// Each entry: { token, userId, family, createdAt, expiresAt, isRevoked }
const refreshTokens = [];

module.exports = { users, refreshTokens };

Step 5: Helper Functions for Token Management

Create utils/tokenUtils.js:

const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const { refreshTokens } = require('../store');

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

function generateRefreshToken(user, family) {
  const tokenFamily = family || uuidv4();
  const token = jwt.sign(
    { userId: user.id, family: tokenFamily },
    process.env.REFRESH_TOKEN_SECRET,
    { expiresIn: process.env.REFRESH_TOKEN_EXPIRY }
  );

  // Store the refresh token on the server
  refreshTokens.push({
    token,
    userId: user.id,
    family: tokenFamily,
    createdAt: new Date(),
    isRevoked: false
  });

  return { token, family: tokenFamily };
}

function revokeTokenFamily(family) {
  refreshTokens.forEach(rt => {
    if (rt.family === family) {
      rt.isRevoked = true;
    }
  });
}

function revokeAllUserTokens(userId) {
  refreshTokens.forEach(rt => {
    if (rt.userId === userId) {
      rt.isRevoked = true;
    }
  });
}

function findRefreshToken(token) {
  return refreshTokens.find(rt => rt.token === token);
}

module.exports = {
  generateAccessToken,
  generateRefreshToken,
  revokeTokenFamily,
  revokeAllUserTokens,
  findRefreshToken
};

Notice the family field. Every refresh token belongs to a family. When we rotate, the new token inherits the same family. If a revoked token from that family is reused, we revoke the entire family.

Step 6: Authentication Routes

Create routes/auth.js:

const express = require('express');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const { users } = require('../store');
const {
  generateAccessToken,
  generateRefreshToken,
  revokeTokenFamily,
  revokeAllUserTokens,
  findRefreshToken
} = require('../utils/tokenUtils');

const router = express.Router();

// ---------------------
// REGISTER
// ---------------------
router.post('/register', async (req, res) => {
  try {
    const { email, password } = req.body;

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

    const existingUser = users.find(u => u.email === email);
    if (existingUser) {
      return res.status(409).json({ error: 'User already exists' });
    }

    const hashedPassword = await bcrypt.hash(password, 12);
    const user = {
      id: Date.now().toString(),
      email,
      password: hashedPassword
    };
    users.push(user);

    res.status(201).json({ message: 'User registered successfully' });
  } catch (err) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

// ---------------------
// LOGIN
// ---------------------
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;

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

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

    const accessToken = generateAccessToken(user);
    const { token: refreshToken } = generateRefreshToken(user);

    // Send refresh token as HTTP-only cookie
    res.cookie('refreshToken', refreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'Strict',
      maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
    });

    res.json({ accessToken });
  } catch (err) {
    res.status(500).json({ error: 'Internal server error' });
  }
});

// ---------------------
// REFRESH TOKEN
// ---------------------
router.post('/refresh', (req, res) => {
  const token = req.cookies.refreshToken;

  if (!token) {
    return res.status(401).json({ error: 'Refresh token not provided' });
  }

  // Find the token in our store
  const storedToken = findRefreshToken(token);

  if (!storedToken) {
    return res.status(403).json({ error: 'Invalid refresh token' });
  }

  // REUSE DETECTION: if the token is revoked, someone is reusing an old token
  if (storedToken.isRevoked) {
    // Revoke the entire family to protect the user
    revokeTokenFamily(storedToken.family);
    res.clearCookie('refreshToken');
    return res.status(403).json({ error: 'Token reuse detected. All sessions revoked.' });
  }

  // Verify the JWT signature and expiration
  try {
    const decoded = jwt.verify(token, process.env.REFRESH_TOKEN_SECRET);
    const user = users.find(u => u.id === decoded.userId);

    if (!user) {
      return res.status(403).json({ error: 'User not found' });
    }

    // Revoke the current refresh token (it is now used)
    storedToken.isRevoked = true;

    // Issue new tokens (rotation)
    const accessToken = generateAccessToken(user);
    const { token: newRefreshToken } = generateRefreshToken(user, storedToken.family);

    res.cookie('refreshToken', newRefreshToken, {
      httpOnly: true,
      secure: true,
      sameSite: 'Strict',
      maxAge: 7 * 24 * 60 * 60 * 1000
    });

    res.json({ accessToken });
  } catch (err) {
    storedToken.isRevoked = true;
    res.clearCookie('refreshToken');
    return res.status(403).json({ error: 'Invalid or expired refresh token' });
  }
});

// ---------------------
// LOGOUT
// ---------------------
router.post('/logout', (req, res) => {
  const token = req.cookies.refreshToken;

  if (token) {
    const storedToken = findRefreshToken(token);
    if (storedToken) {
      // Revoke the entire token family
      revokeTokenFamily(storedToken.family);
    }
  }

  res.clearCookie('refreshToken');
  res.json({ message: 'Logged out successfully' });
});

module.exports = router;

Step 7: Middleware to Protect Routes

Create middleware/authenticate.js:

const jwt = require('jsonwebtoken');

function authenticate(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' });
  }

  try {
    const decoded = jwt.verify(token, process.env.ACCESS_TOKEN_SECRET);
    req.user = decoded;
    next();
  } catch (err) {
    return res.status(403).json({ error: 'Invalid or expired access token' });
  }
}

module.exports = authenticate;

Step 8: Add a Protected Route

Add this to server.js before the app.listen call:

const authenticate = require('./middleware/authenticate');

app.get('/api/profile', authenticate, (req, res) => {
  res.json({
    message: 'This is protected data',
    user: req.user
  });
});

Testing the Full Authentication Flow

Here is the complete flow you can test using a tool like Postman, Insomnia, or cURL:

  1. Register: POST /api/auth/register with {"email": "[email protected]", "password": "securePassword123"}
  2. Login: POST /api/auth/login with the same credentials. You will receive an access token in the response body and a refresh token in an HTTP-only cookie.
  3. Access a protected route: GET /api/profile with the header Authorization: Bearer <accessToken>
  4. Refresh the token: POST /api/auth/refresh (the cookie is sent automatically). You receive a new access token and a new refresh token cookie.
  5. Logout: POST /api/auth/logout to revoke the entire token family and clear the cookie.

Storing Refresh Tokens Securely: Your Options

In this tutorial, we used an in-memory array for simplicity. In a real application, you need a persistent and secure store. Here are the most common options:

Storage Option Pros Cons Best For
PostgreSQL / MySQL Persistent, queryable, supports indexing Slightly slower than in-memory stores Most production apps
MongoDB Flexible schema, TTL indexes for auto-cleanup Requires separate infrastructure if not already used Apps already using MongoDB
Redis Extremely fast, built-in TTL expiration Data is in-memory (can be lost on restart without persistence config) High-traffic applications

Pro tip: Whichever store you choose, always hash your refresh tokens before saving them. This way, even if the database is compromised, the raw tokens are not exposed. You can use a fast hash like SHA-256 for this purpose (bcrypt is overkill for tokens since they are already random).

Security Best Practices for JWT Refresh Tokens in Node.js

Getting the code right is only half the battle. Follow these best practices to keep your implementation secure:

  • Always use HTTP-only, Secure, SameSite cookies for refresh tokens. This prevents JavaScript from accessing the token, mitigating XSS attacks.
  • Keep access token lifetimes short (5 to 15 minutes). This limits the window of abuse if a token is leaked.
  • Implement refresh token rotation as shown above. Never reuse the same refresh token indefinitely.
  • Detect token reuse and revoke the entire token family when it happens. This is your safety net against stolen tokens.
  • Use strong, unique secrets for signing access and refresh tokens. Do not use the same secret for both.
  • Set up HTTPS in production. Cookies marked as Secure will not be sent over plain HTTP.
  • Clean up expired tokens from your database regularly using a scheduled job or TTL-based expiration.
  • Rate-limit the refresh endpoint to prevent brute-force attacks. Consider using packages like express-rate-limit.
  • Log refresh token usage for auditing. If a user reports suspicious activity, you can trace token family usage.

Handling Token Expiration on the Client Side

Your front-end application needs to handle the moment when an access token expires. Here is a recommended approach using an Axios interceptor (or any HTTP client equivalent):

// Axios interceptor example
axios.interceptors.response.use(
  response => response,
  async error => {
    const originalRequest = error.config;

    if (error.response.status === 403 && !originalRequest._retry) {
      originalRequest._retry = true;

      try {
        const { data } = await axios.post('/api/auth/refresh', {}, {
          withCredentials: true
        });

        // Update the access token
        axios.defaults.headers.common['Authorization'] = `Bearer ${data.accessToken}`;
        originalRequest.headers['Authorization'] = `Bearer ${data.accessToken}`;

        return axios(originalRequest);
      } catch (refreshError) {
        // Refresh failed, redirect to login
        window.location.href = '/login';
        return Promise.reject(refreshError);
      }
    }

    return Promise.reject(error);
  }
);

This interceptor automatically attempts to refresh the access token when a 403 response is received, then retries the original request. If the refresh itself fails (for example, the refresh token is expired or revoked), it redirects the user to the login page.

Complete Project Structure

Here is what your final project folder should look like:

jwt-refresh-demo/
├── .env
├── server.js
├── store.js
├── routes/
│   └── auth.js
├── middleware/
│   └── authenticate.js
├── utils/
│   └── tokenUtils.js
├── package.json
└── package-lock.json

Revoking All Tokens on Password Change

One scenario many tutorials skip: what happens when a user changes their password? You should revoke all existing refresh tokens for that user. This forces all active sessions to re-authenticate with the new password.

// Inside your password change route
const { revokeAllUserTokens } = require('../utils/tokenUtils');

router.post('/change-password', authenticate, async (req, res) => {
  const { currentPassword, newPassword } = req.body;
  const user = users.find(u => u.id === req.user.userId);

  const isMatch = await bcrypt.compare(currentPassword, user.password);
  if (!isMatch) {
    return res.status(401).json({ error: 'Current password is incorrect' });
  }

  user.password = await bcrypt.hash(newPassword, 12);

  // Revoke ALL refresh tokens for this user
  revokeAllUserTokens(user.id);

  res.clearCookie('refreshToken');
  res.json({ message: 'Password changed. Please log in again.' });
});

Common Mistakes to Avoid

After working with many Node.js authentication implementations, here are the most frequent mistakes we see at Box Software:

  1. Storing refresh tokens in localStorage. This makes them vulnerable to XSS attacks. Always use HTTP-only cookies.
  2. Using the same secret for access and refresh tokens. If one is compromised, the other remains safe when they use separate secrets.
  3. Never revoking refresh tokens. Without a server-side store and revocation logic, you have no way to end a session.
  4. Setting refresh tokens to never expire. Even with rotation, always set an absolute expiration (7 to 30 days is typical).
  5. Skipping HTTPS. Without HTTPS, cookies and tokens can be intercepted in transit.
  6. Not implementing reuse detection. Token rotation without reuse detection only solves half the problem.

When to Use This Approach vs. Session-Based Auth

JWT refresh tokens are not always the right choice. Here is a quick comparison to help you decide:

Criteria JWT + Refresh Tokens Server-Side Sessions
Statelessness Access token is stateless; refresh token requires server storage Fully stateful
Scalability Easier to scale horizontally Requires shared session store (e.g., Redis)
Mobile / API clients Excellent fit Requires cookie handling on client
Revocation Requires extra logic (as shown above) Simple: delete the session
Complexity Higher Lower

Use JWT refresh tokens when you are building APIs consumed by mobile apps, SPAs, or microservices. Use sessions when you have a simple server-rendered application where simplicity is more important than horizontal scaling.

Frequently Asked Questions

What happens if the refresh token is stolen?

With refresh token rotation and reuse detection, if an attacker uses a stolen token after the legitimate user has already rotated it, the server detects the reuse and revokes the entire token family. The legitimate user will need to log in again, but the attacker is locked out too. Without rotation, a stolen refresh token could be used until it expires, which is why rotation is critical.

Should I store refresh tokens in a database or Redis?

Both work well. Redis is ideal for high-traffic applications because of its speed and built-in TTL support. A relational database like PostgreSQL is better when you need to query token usage history for auditing. Many production systems use both: Redis for fast lookups and a relational database for long-term audit logs.

How long should a refresh token last?

A common range is 7 to 30 days. The exact duration depends on your security requirements. Banking applications might use 1 day, while a social media app might use 30 days. With rotation, longer lifetimes are more acceptable because each token is only valid for a single use.

Can I use the same JWT secret for access and refresh tokens?

You can, but you should not. Using separate secrets means that compromising one does not automatically compromise the other. It is a simple precaution that significantly improves your security posture.

Do I need refresh tokens if my access token lasts 24 hours?

A 24-hour access token is a security risk because it cannot be revoked (JWTs are stateless). If it is leaked, an attacker has access for up to 24 hours. Short-lived access tokens combined with refresh tokens give you both security and a good user experience.

How do I handle multiple devices?

Each device gets its own token family. When a user logs in from a phone and a laptop, two separate families are created. Logging out from one device revokes only that family, leaving the other active. The revokeAllUserTokens function we built above handles the case where you want to log out from all devices.

Wrapping Up

Implementing JWT refresh tokens in Node.js requires careful attention to security, but the pattern is straightforward once you understand the moving parts. To summarize what we built:

  • Short-lived access tokens (15 minutes) for API authorization
  • Long-lived refresh tokens (7 days) stored server-side and sent via HTTP-only cookies
  • Refresh token rotation that issues a new refresh token on every use
  • Reuse detection that revokes entire token families when suspicious activity is detected
  • Clean logout that invalidates all tokens in the family

This approach follows current security best practices and gives your users a seamless authentication experience. You can extend it further by adding rate limiting, IP-based anomaly detection, or multi-factor authentication for sensitive operations.

If you need help implementing secure authentication in your Node.js application, the team at Box Software is always happy to assist. Feel free to reach out to us for a consultation.