Introduction to Refresh Tokens

In previous lessons, we explored JWT fundamentals, common vulnerabilities, and defenses like token blacklisting and short-lived token expiration. While these improve security, they can negatively impact user experience by requiring frequent re-authentication.

Refresh tokens address this by allowing clients to obtain new access tokens without re-authentication. They work alongside access tokens:

  • Access tokens: Short-lived, used for API access
  • Refresh tokens: Long-lived, used to get new access tokens

This separation follows the principle of least privilege. Token rotation enhances security by generating a new refresh token each time one is used, preventing replay attacks. Let's implement this system to address identified vulnerabilities.

Implementing Refresh Token Functionality

To implement refresh tokens, we need to modify our authentication system to generate, store, and validate both access and refresh tokens. Let's start by examining how we can create and manage these tokens.

Note: In most applications, users don’t manually request a new access token. Instead, the frontend (UI) automatically handles it in the background. This is done using refresh tokens whenever an access token expires.

First, we'll need to generate both tokens during login. We'll add a unique jti (JWT ID) claim to each refresh token to help us track and manage them securely. We generate this jti using Math.random().toString(36).substring(2), which creates a random string for each token:

// Helper functions for token generation
const generateAccessToken = (userId: number) => {
  return jwt.sign({ userId }, JWT_SECRET_KEY, { expiresIn: '15m' });  // Short-lived token (15 minutes)
};

const generateRefreshToken = (userId: number) => {
  // Generate a unique jti using Math.random
  return jwt.sign(
    { userId, jti: Math.random().toString(36).substring(2) }, // Unique JWT ID
    JWT_SECRET_KEY,
    { expiresIn: '7d' }
  );
};

Why use the jti claim in refresh tokens?
The jti (JWT ID) claim provides a unique identifier for each refresh token. By assigning a unique jti to every token, the server can track and manage individual tokens—allowing it to invalidate a specific token after use (token rotation) or in case of compromise. This is essential for security because it prevents replay attacks: if a refresh token is stolen, it cannot be reused once its jti has been invalidated. Using jti enables fine-grained control over token validity and enforces single-use refresh tokens during rotation.

Now, let's see how these tokens are used during login:

// Generate both access and refresh tokens during login
router.post('/login', async (req, res) => {
    const { username, password } = req.body;
    try {
        const user = await User.findOne({ where: { username } });
        if (user && await bcrypt.compare(password, user.password)) {
            // Generate tokens using helper functions
            const accessToken = generateAccessToken(user.id);
            const refreshToken = generateRefreshToken(user.id);
            
            // Store tokens in HTTP-only cookies
            res.cookie('token', accessToken, {
                httpOnly: true,
                secure: process.env.NODE_ENV === 'production',
                sameSite: 'strict',
                maxAge: 15 * 60 * 1000 // 15 minutes
            });
            
            res.cookie('refreshToken', refreshToken, {
                httpOnly: true,
                secure: process.env.NODE_ENV === 'production',
                sameSite: 'strict',
                maxAge: 7 * 24 * 60 * 60 * 1000 // 7 days
            });
            
            return res.json({ message: 'Login successful' });
        }
        res.status(401).json({ error: 'Invalid credentials' });
    } catch (error) {
        console.error('Error during login:', error);
        res.status(500).json({ error: 'Internal server error' });
    }
});

In this code, we're generating two tokens: a short-lived access token (15 minutes) and a longer-lived refresh token (7 days) with a unique jti claim. Both are stored as HTTP-only cookies to protect against XSS attacks. The access token is used for regular API access, while the refresh token will be used to obtain new access tokens when the current one expires.

Next, we need to create a /refresh endpoint that allows clients to exchange a valid refresh token for a new access token:

// Helper functions for token generation
const generateAccessToken = (userId: number) => {
  return jwt.sign({ userId }, JWT_SECRET_KEY, { expiresIn: '15m' });  // Short-lived token (15 minutes)
};

// Refresh token endpoint
router.post('/refresh', async (req, res) => {
    const refreshToken = req.cookies.refreshToken;
    
    if (!refreshToken) {
        return res.status(401).json({ error: 'Refresh token not provided' });
    }
    
    try {
        // Verify the refresh token
        const decoded = jwt.verify(refreshToken, JWT_SECRET_KEY);
        
        // Find the user
        const user = await User.findByPk(decoded.userId);
        if (!user) {
            return res.status(401).json({ error: 'User not found' });
        }
        
        // Generate a new access token using the helper function
        const newAccessToken = generateAccessToken(user.id);
        
        // Set the new access token as a cookie
        res.cookie('token', newAccessToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'strict',
            maxAge: 15 * 60 * 1000 // 15 minutes
        });
        
        return res.json({ message: 'Token refreshed successfully' });
    } catch (error) {
        return res.status(401).json({ error: 'Invalid refresh token' });
    }
});

This endpoint extracts the refresh token from cookies, verifies it, and if valid, issues a new access token. The client can call this endpoint whenever the access token expires, allowing for continuous authentication without requiring the user to log in again.

However, this implementation still has a security issue: if a refresh token is stolen, it can be used repeatedly until it expires (7 days in our example). This is where token rotation comes in.

Implementing Token Rotation

Token rotation adds an important security layer by ensuring that refresh tokens are single-use. Each time a refresh token is used, it's invalidated and a new one is issued. This prevents an attacker who has stolen a refresh token from using it after the legitimate user has already used it.

To implement token rotation, we need to:

  1. Track all issued refresh tokens (using their jti)
  2. Invalidate a refresh token after it's used
  3. Issue a new refresh token with each refresh request

Let's break down the implementation step by step.

1. Storing Refresh Tokens

First, we need a way to keep track of issued refresh tokens. We'll use the jti claim as a unique identifier for each refresh token. For demonstration, we'll use an in-memory store (a Map). In production, use a persistent database.

// In-memory store for refresh tokens: userId -> Set of valid jtis
const refreshTokenStore = new Map();
2. Storing Refresh Tokens on Login

When a user logs in, generate a refresh token with a unique jti and store the jti:

// Helper functions for token generation
const generateAccessToken = (userId: number) => {
  return jwt.sign({ userId }, JWT_SECRET_KEY, { expiresIn: '15m' });
};

const generateRefreshToken = (userId: number) => {
  // Generate a unique jti using Math.random
  return jwt.sign(
    { userId, jti: Math.random().toString(36).substring(2) },
    JWT_SECRET_KEY,
    { expiresIn: '7d' }
  );
};

// Modified login endpoint to store refresh token jti
router.post('/login', async (req, res) => {
    const { username, password } = req.body;
    try {
        const user = await User.findOne({ where: { username } });
        if (user && await bcrypt.compare(password, user.password)) {
            const accessToken = generateAccessToken(user.id);
            const refreshToken = generateRefreshToken(user.id);

            // Decode the refresh token to extract jti
            const { jti } = jwt.decode(refreshToken);

            // Store the refresh token's jti
            let userJtis = refreshTokenStore.get(user.id) || new Set();
            userJtis.add(jti);
            refreshTokenStore.set(user.id, userJtis);

            // Set cookies
            res.cookie('token', accessToken, {
                httpOnly: true,
                secure: process.env.NODE_ENV === 'production',
                sameSite: 'strict',
                maxAge: 15 * 60 * 1000
            });
            res.cookie('refreshToken', refreshToken, {
                httpOnly: true,
                secure: process.env.NODE_ENV === 'production',
                sameSite: 'strict',
                maxAge: 7 * 24 * 60 * 60 * 1000
            });

            return res.json({ message: 'Login successful' });
        }
        res.status(401).json({ error: 'Invalid credentials' });
    } catch (error) {
        console.error('Error during login:', error);
        res.status(500).json({ error: 'Internal server error' });
    }
});
3. Validating and Rotating Refresh Tokens

When a refresh token is used, we need to:

  • Check if its jti exists in our store
  • Remove (invalidate) it
  • Issue a new refresh token and store its jti
// Modified refresh endpoint with token rotation using jti
router.post('/refresh', async (req, res) => {
    const refreshToken = req.cookies.refreshToken;

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

    try {
        // Verify the refresh token
        const decoded = jwt.verify(refreshToken, JWT_SECRET_KEY);
        const userId = decoded.userId;
        const jti = decoded.jti;

        // Check if the jti exists in our store
        let userJtis = refreshTokenStore.get(userId) || new Set();
        if (!userJtis.has(jti)) {
            return res.status(401).json({ error: 'Invalid refresh token' });
        }
        const user = await User.findByPk(userId);
        if (!user) {
            return res.status(401).json({ error: 'User not found' });
        }
        // Remove the used jti (token rotation)
        userJtis.delete(jti);

        // Generate new tokens
        const newAccessToken = generateAccessToken(user.id);
        const newRefreshToken = generateRefreshToken(user.id);

        // Store the new refresh token's jti
        const { jti: newJti } = jwt.decode(newRefreshToken);
        userJtis.add(newJti);
        refreshTokenStore.set(userId, userJtis);

        // Set the new tokens as cookies
        res.cookie('token', newAccessToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'strict',
            maxAge: 15 * 60 * 1000
        });
        res.cookie('refreshToken', newRefreshToken, {
            httpOnly: true,
            secure: process.env.NODE_ENV === 'production',
            sameSite: 'strict',
            maxAge: 7 * 24 * 60 * 60 * 1000
        });

        return res.json({ message: 'Tokens refreshed successfully' });
    } catch (error) {
        return res.status(401).json({ error: 'Invalid refresh token' });
    }
});
Summary

Refresh tokens solve the user experience problem of frequent re-authentication when using short-lived access tokens. This lesson introduces the dual-token system where short-lived access tokens handle API access while long-lived refresh tokens obtain new access tokens without requiring users to log in again. We implemented refresh token functionality in our authentication system, including secure token generation, storage, and validation. We then enhanced security through token rotation, making refresh tokens single-use by invalidating them after use and issuing new ones, tracked by their unique jti claim. The jti is generated using Math.random().toString(36).substring(2) to ensure each token is unique and unpredictable, which is critical for preventing replay attacks and enforcing single-use tokens.

In the upcoming practice, you'll apply these concepts by implementing a secure token rotation system to protect your application. 🔄🔐

Sign up
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal