Implementing JWT Refresh Tokens in Node.js for Secure User Authentication

November 2, 2024 (2w ago)

Implementing JWT Refresh Tokens in Node.js for Secure User Authentication

While JWTs (JSON Web Tokens) are a secure way to handle authentication, they are often short-lived, with a limited expiration time to reduce security risks if compromised. To maintain user sessions without requiring frequent re-authentication, refresh tokens allow you to renew access tokens safely. This guide walks through implementing refresh tokens in a Node.js application using Express and Mongoose, allowing for secure and scalable session management.


Why Use Refresh Tokens?

Access tokens usually have short expiration times (e.g., 15 minutes to 1 hour). If an access token expires, the user would have to log in again, which could lead to poor user experience. Refresh tokens enable the client to obtain a new access token without requiring the user to log in, allowing for a seamless experience while keeping sessions secure.

How Refresh Tokens Work

  1. Access Token: Short-lived token used for authorizing requests.
  2. Refresh Token: Long-lived token stored securely on the client side. It’s used to request a new access token when the access token expires.

The flow looks like this:

  1. The server issues an access token and a refresh token during login.
  2. The client stores the refresh token securely (e.g., in an HTTP-only cookie).
  3. When the access token expires, the client uses the refresh token to obtain a new access token.
  4. The server verifies the refresh token, issues a new access token, and, optionally, a new refresh token.

Setting Up the Project

We’ll build upon the previous JWT setup by adding refresh token functionality. Ensure you have Express, Mongoose, jsonwebtoken, and bcryptjs installed.


Step 1: Updating the Auth Routes

In the auth.js route file, we’ll add functionality to handle refresh tokens.

Generate Access and Refresh Tokens

Let’s create functions to generate an access token and a refresh token.

const jwt = require("jsonwebtoken");
 
const generateAccessToken = (user) => {
  return jwt.sign({ id: user._id }, process.env.JWT_SECRET, { expiresIn: "15m" }); // Short-lived access token
};
 
const generateRefreshToken = (user) => {
  return jwt.sign({ id: user._id }, process.env.JWT_REFRESH_SECRET, { expiresIn: "7d" }); // Long-lived refresh token
};

Here, the generateAccessToken function generates a short-lived token (e.g., 15 minutes), and generateRefreshToken generates a token with a longer lifespan (e.g., 7 days).

Modify the Register and Login Routes

Update the register and login routes to issue both access and refresh tokens.

routes/auth.js

const express = require("express");
const jwt = require("jsonwebtoken");
const User = require("../models/User");
 
const router = express.Router();
 
router.post("/register", async (req, res) => {
  try {
    const { username, email, password } = req.body;
    const existingUser = await User.findOne({ email });
    if (existingUser) return res.status(400).json({ message: "Email already registered" });
 
    const user = new User({ username, email, password });
    await user.save();
 
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);
 
    res.status(201).json({ user, accessToken, refreshToken });
  } catch (error) {
    res.status(500).json({ message: error.message });
  }
});
 
router.post("/login", async (req, res) => {
  try {
    const { email, password } = req.body;
    const user = await User.findOne({ email });
    if (!user) return res.status(404).json({ message: "User not found" });
 
    const isPasswordValid = await user.comparePassword(password);
    if (!isPasswordValid) return res.status(400).json({ message: "Invalid credentials" });
 
    const accessToken = generateAccessToken(user);
    const refreshToken = generateRefreshToken(user);
 
    res.status(200).json({ user, accessToken, refreshToken });
  } catch (error) {
    res.status(500).json({ message: error.message });
  }
});

In this update:


Step 2: Implementing the Refresh Token Endpoint

Next, let’s add a new endpoint to handle token refreshing.

Refresh Token Route

Create a refreshToken route that verifies the refresh token and issues a new access token.

routes/auth.js

router.post("/refresh-token", async (req, res) => {
  const { token } = req.body;
  if (!token) return res.status(401).json({ message: "Refresh token required" });
 
  try {
    const decoded = jwt.verify(token, process.env.JWT_REFRESH_SECRET);
    const user = await User.findById(decoded.id);
    if (!user) return res.status(404).json({ message: "User not found" });
 
    const newAccessToken = generateAccessToken(user);
    res.status(200).json({ accessToken: newAccessToken });
  } catch (error) {
    res.status(403).json({ message: "Invalid refresh token" });
  }
});

This route:

  1. Accepts the refresh token in the request body.
  2. Verifies the refresh token using the JWT_REFRESH_SECRET.
  3. If valid, it issues a new access token.

Step 3: Storing and Handling Tokens on the Client

To keep tokens secure, follow best practices for storing and sending tokens.

Securely Storing Tokens

  1. Access Tokens: Store in memory (not in local storage or session storage) to prevent XSS attacks.
  2. Refresh Tokens: Store in an HTTP-only, secure cookie. This prevents JavaScript access to the refresh token, reducing the risk of XSS attacks.

Example: Sending Tokens as Cookies

In the login and register routes, set the refresh token as an HTTP-only cookie.

res.cookie("refreshToken", refreshToken, {
  httpOnly: true,
  secure: true, // Set to true if using HTTPS
  sameSite: "Strict"
});
res.status(200).json({ user, accessToken });

The refresh token is sent as a cookie, while the access token is returned in the response body for client-side storage in memory.

Using Tokens in Requests

For each API request, include the access token in the Authorization header:

Authorization: Bearer <accessToken>

When the access token expires, send a request to the /refresh-token endpoint, and retrieve a new access token.


Step 4: Middleware for Protected Routes

Create a middleware to verify the access token for protected routes, as done in the previous JWT guide.

middleware/authMiddleware.js

const jwt = require("jsonwebtoken");
 
const authMiddleware = (req, res, next) => {
  const token = req.headers.authorization?.split(" ")[1];
  if (!token) return res.status(401).json({ message: "Access denied. No token provided." });
 
  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(403).json({ message: "Invalid or expired access token" });
  }
};
 
module.exports = authMiddleware;

This middleware checks the access token validity before granting access to protected routes.


Best Practices for JWT and Refresh Tokens

  1. Short Access Token Expiration: Use a short expiration (e.g., 15 minutes) to minimize security risks if the token is compromised.
  2. Longer Refresh Token Expiration: Use a longer expiration (e.g., 7 days or more) for refresh tokens.
  3. HTTP-Only Cookies for Refresh Tokens: Store refresh tokens in HTTP-only cookies to prevent JavaScript access and mitigate XSS risks.
  4. Secure Token Storage: Avoid storing tokens in local storage. Use memory storage for access tokens and HTTP-only cookies for refresh tokens.
  5. Logout and Token Revocation: Implement a mechanism to revoke refresh tokens on logout or detect compromised tokens.

Conclusion

Implementing refresh tokens with JWT in a Node.js application adds a layer of security and flexibility to user authentication. With a combination of short-lived access tokens and long-lived refresh tokens, you can improve the user experience by allowing continuous sessions while maintaining robust security.

This approach is ideal for modern applications that need scalable, stateless authentication. Integrate these techniques in your projects to secure user sessions and manage authentication with ease and security.