Introduction

Welcome to the lesson on implementing a token refresh mechanism! In our previous lesson, we explored the basics of secure token-based authentication and the importance of using cookies and JWT expiration to enhance security. Now, we'll dive deeper into how we can extend user sessions securely using refresh tokens. This mechanism is crucial for maintaining seamless user experiences while ensuring robust security in web applications. Let's get started! 🚀

Understanding Access and Refresh Tokens

Now we know that access tokens, which are used to authenticate user requests, need to be short-lived. They have a limited lifespan to minimize the risk of misuse if compromised. However, this short lifespan can disrupt user sessions, requiring frequent re-authentication. This is where refresh tokens come into play. Refresh tokens are long-lived and can be used to obtain new access tokens without requiring the user to log in again. This combination allows for secure, uninterrupted user sessions.

Frontend Implementation

Once we understand the role of access and refresh tokens, let's see how the token refresh mechanism is applied on the frontend. This will also give us a clear picture of what to expect from the backend implementation. On the frontend, we need to implement a function to refresh the access token and a wrapper to manage token expiration. This ensures a seamless user experience without frequent re-authentication.

Wrapper Function to Handle Token Expiration

If previously, fetching some url response required one-time query, now it becomes a two-step operation. Here we provide the wrapper function that will handle token expiration. This function will intercept requests and check if the access token has expired. If it has, the function will attempt to refresh the token:

// Wrapper function to handle token expiration
const fetchWithToken = async (url: string, options: RequestInit) => {
  let response = await fetch(url, { ...options, credentials: 'include' });
  if (response.status === 401) { // If unauthorized, try refreshing the token
    const success = await refreshAccessToken();
    if (success) {
      response = await fetch(url, { ...options, credentials: 'include' });
    }
  }
  return response;
};

This function makes an initial request and checks if the response status is 401 Unauthorized, indicating that the access token may have expired. If so, it calls the refreshAccessToken function to attempt a token refresh. If the refresh is successful, it retries the original request.

Function to Refresh the Access Token

Next, we implement the function responsible for refreshing the access token. This function will be called by the wrapper function when a token refresh is needed:

// Function to refresh the access token
const refreshAccessToken = async () => {
  try {
    const response = await fetch('https://api.example.com/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      credentials: 'include' // Ensure cookies are sent with the request
    });

    if (!response.ok) {
      throw new Error('Failed to refresh token');
    }

    // No need to handle accessToken directly if it's set via cookies
    return true;
  } catch (error) {
    console.error('Error refreshing token:', error);
    navigate('/login'); // Redirect to login if refresh fails
  }
};

This function retrieves the stored refresh token and sends a request to the backend to obtain a new access token. If the refresh is successful, it returns true, allowing the wrapper function to retry the original request. If the refresh fails, it redirects to the login route.

These snippets illustrate how to handle token expiration and refresh the access token on the frontend, setting the stage for the backend implementation that will support this functionality.

Exploiting Token Vulnerabilities

Before we implement a secure token refresh mechanism, it's important to understand potential vulnerabilities. Attackers can exploit improperly managed tokens to gain unauthorized access. Let's see how an attacker might exploit a vulnerability in token handling:

# Simulating an attack by using a stolen refresh token
curl -X POST http://api.pastebin.com/api/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "stolen_refresh_token"}'

In this example, an attacker uses a stolen refresh token to request a new access token. If the server doesn't properly validate the token, the attacker could gain unauthorized access. This highlights the importance of secure token management and demonstrates the critical need for secure token handling and validation.

Token Generation and Invalidation

The first step in implementing a token refresh mechanism is to generate new access and refresh tokens. It's also important to invalidate the old refresh token to prevent reuse:

import jwt from 'jsonwebtoken';
import { JWT_SECRET_KEY, JWT_REFRESH_SECRET } from '../../config';

// Array to store refresh tokens
let refreshTokens: string[] = [];

function generateTokens(userId: string) {
  const accessToken = jwt.sign(
    { userId, type: 'access' },
    JWT_SECRET_KEY,
    { expiresIn: '15m' }
  );

  const refreshToken = jwt.sign(
    { userId, type: 'refresh' },
    JWT_REFRESH_SECRET,
    { expiresIn: '7d' }
  );

  // Store refresh token securely
  refreshTokens.add(refreshToken);

  return { accessToken, refreshToken };
}

In this code, we generate new tokens and store the refresh token securely. This ensures that each token can be invalidated if necessary.

Authentication Checks

After generating the tokens, the next step is to ensure proper authentication. We need to verify that the user is authenticated before allowing them to refresh their tokens:

import { Request, Response } from 'express';

export async function handleRefreshToken(req: Request, res: Response) {
  const refreshTokenCookie = req.cookies.refresh_token;
  if (!refreshTokenCookie) {
    return res.status(401).json({ error: 'Refresh token required' });
  }
  // ... rest of the code
}

This code checks for the presence of a refresh token in the request cookies. If the token is missing, the server responds with an error, indicating that authentication is required.

Token Validation

Once we've ensured authentication, the next step is to validate the refresh token. This involves checking that the token is valid and has not been tampered with:

try {
  const payload = jwt.verify(refreshTokenCookie, JWT_REFRESH_SECRET) as jwt.JwtPayload;
  if (payload.type !== 'refresh') {
    throw new Error('Invalid token type');
  }
  // ... rest of the code
} catch (error) {
  return res.status(403).json({ error: 'Invalid refresh token' });
}

Here, we use the jsonwebtoken library to verify the refresh token against our secret key. We also check that the token type is refresh to ensure it's being used correctly. If the token is invalid, we respond with an error.

Integrating the Refresh Token Function

To integrate the handleRefreshToken function into your Express application, you can set up a router:

import express from 'express';
import { handleRefreshToken } from './path/to/your/refreshTokenHandler';

const router = express.Router();

router.post('/auth/refresh', handleRefreshToken);

export default router;

And then, in your main application file, use this router:

import express from 'express';
import refreshTokenRouter from './path/to/your/router';

const app = express();

app.use('/api', refreshTokenRouter);

// ... other routes and middleware

app.listen(3000, () => {
  console.log('Server is running on port 3000');
});

In this setup, the handleRefreshToken function is associated with the /api/auth/refresh endpoint using an Express router. This approach helps organize routes and middleware more modularly.

Token Revocation

In a secure authentication system, it's important to have a mechanism for revoking refresh tokens. Token revocation allows you to invalidate tokens when necessary, such as when a user logs out, a token is suspected to be compromised, or a user wants to terminate a session.

To enable token revocation, refresh tokens should be stored in a persistent storage solution, such as a database. This allows you to track and manage active tokens effectively.

When a token needs to be revoked, you can remove it from the storage or mark it as invalid. Here's an example of how you might implement token revocation:

import { Request, Response } from 'express';

let refreshTokens: string[] = []; // Array to store refresh tokens

export async function revokeRefreshToken(req: Request, res: Response) {
  const refreshToken = req.cookies.refreshToken;
  if (!refreshToken) {
    return res.status(400).json({ error: 'Refresh token required' });
  }

  // Remove the token from the array
  const index = refreshTokens.indexOf(refreshToken);
  if (index !== -1) {
    refreshTokens.splice(index, 1);
    return res.status(200).json({ message: 'Token revoked successfully' });
  } else {
    return res.status(404).json({ error: 'Token not found' });
  }
}
Integrating Token Revocation

To integrate token revocation into your application, you can set up an endpoint that allows users to revoke their tokens:

import express from 'express';
import { revokeRefreshToken } from './path/to/your/revokeTokenHandler';

const router = express.Router();

router.post('/auth/logout', revokeRefreshToken);

export default router;
Considerations
  • User Experience: Provide users with the ability to view and manage their active sessions, including the option to revoke tokens.
  • Security: Ensure that only authorized users can revoke their tokens. Implement proper authentication and authorization checks on the revocation endpoint.
  • Audit Logging: Consider logging token revocation events for auditing and monitoring purposes.

By implementing token revocation, you enhance the security of your authentication system, providing users with greater control over their sessions and reducing the risk of unauthorized access.

Conclusion and Next Steps

In this lesson, we explored the implementation of a token refresh mechanism to extend user sessions securely. We learned about the differences between access and refresh tokens, examined potential vulnerabilities, and implemented a secure solution using TypeScript and Express. We also looked at how to handle token refresh on the frontend to maintain a seamless user experience. As you move on to the practice exercises, remember the importance of secure token management in web applications. Keep these concepts in mind as you continue to build more secure and robust applications. Happy coding! 🎉

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