Building secure and user-friendly authentication in modern web applications, especially with Node.js, often involves JSON Web Tokens (JWTs). While JWTs offer speed, statelessness, and ease of use, their inherent expiration can be a double-edged sword. Short-lived tokens enhance security but frequently log users out, diminishing the user experience. Conversely, long-lived tokens reduce friction but escalate security risks if compromised. This is where refresh tokens emerge as a critical solution.

This guide will break down how JWT and refresh tokens work together, why you need them, and how to implement a secure refresh token strategy in Node.js.

🔑 What Are Refresh Tokens?

Refresh tokens work in conjunction with JWT access tokens to provide a balanced authentication mechanism:

  • JWT Access Token: A short-lived credential (typically 10-30 minutes) used to authorize requests to protected resources. Its brief lifespan minimizes the impact if it’s intercepted.
  • Refresh Token: A long-lived credential (days or weeks) primarily used to obtain new access tokens without requiring the user to re-authenticate with their credentials. It’s stored securely and used less frequently than the access token.

🛠️ Project Setup

Let’s dive into implementing a secure JWT and refresh token strategy within a minimal Express.js application.

1. Install Dependencies

Begin by initializing your Node.js project and installing the necessary dependencies:

npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser dotenv
  • express: Our web framework.
  • jsonwebtoken: For signing and verifying JWTs.
  • bcryptjs: For secure password hashing.
  • cookie-parser: To handle cookies, where refresh tokens will be stored.
  • dotenv: To manage environment variables securely.

⚙️ Basic Server Setup

Set up your Express server to handle JSON requests and parse cookies.

// server.js
const express = require("express");
const jwt = require("jsonwebtoken");
const bcrypt = require("bcryptjs");
const cookieParser = require("cookie-parser");
require("dotenv").config();

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

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

🔐 Generating Tokens

You’ll need distinct functions to generate both short-lived access tokens and long-lived refresh tokens, each signed with a different secret and having different expiration times.

function generateAccessToken(user) {
  return jwt.sign({ id: user.id, email: user.email }, process.env.ACCESS_SECRET, {
    expiresIn: "15m",
  });
}

function generateRefreshToken(user) {
  return jwt.sign({ id: user.id, email: user.email }, process.env.REFRESH_SECRET, {
    expiresIn: "7d",
  });
}

👤 Login Route

Upon successful user login, issue both an access token and a refresh token. The access token is sent directly to the client, while the refresh token is securely stored in an HttpOnly cookie.

const users = []; // mock DB for demonstration

app.post("/register", async (req, res) => {
  const { email, password } = req.body;
  const hashed = await bcrypt.hash(password, 10);
  users.push({ id: users.length + 1, email, password: hashed });
  res.json({ message: "User registered" });
});

app.post("/login", async (req, res) => {
  const { email, password } = req.body;
  const user = users.find(u => u.email === email);
  if (!user) return res.status(400).json({ error: "User not found" });

  const valid = await bcrypt.compare(password, user.password);
  if (!valid) return res.status(400).json({ error: "Invalid password" });

  const accessToken = generateAccessToken(user);
  const refreshToken = generateRefreshToken(user);

  // Store refresh token in HttpOnly cookie for security
  res.cookie("refreshToken", refreshToken, {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production", // enable in production (HTTPS)
    sameSite: "strict",
    maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
  });

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

🔄 Refreshing Tokens

Implement a dedicated /refresh endpoint that allows clients to request a new access token using their refresh token. The refresh token from the HttpOnly cookie is verified, and if valid, a new access token is issued.

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

  jwt.verify(token, process.env.REFRESH_SECRET, (err, user) => {
    if (err) return res.status(403).json({ error: "Invalid refresh token" });

    // Optionally, implement refresh token rotation here:
    // Generate a new refresh token and set it in a new HttpOnly cookie.

    const newAccessToken = generateAccessToken(user);
    res.json({ accessToken: newAccessToken });
  });
});

🔒 Middleware to Protect Routes

Protect your API routes by creating a middleware that verifies the access token present in the request headers.

function authMiddleware(req, res, next) {
  const authHeader = req.headers["authorization"];
  const token = authHeader && authHeader.split(" ")[1]; // Bearer TOKEN
  if (!token) return res.sendStatus(401); // No token, unauthorized

  jwt.verify(token, process.env.ACCESS_SECRET, (err, user) => {
    if (err) return res.sendStatus(403); // Invalid or expired access token, forbidden
    req.user = user;
    next();
  });
}

app.get("/protected", authMiddleware, (req, res) => {
  res.json({ message: "You are authorized to access this protected route!", user: req.user });
});

🚪 Logout

To log a user out, simply clear the HttpOnly refresh token cookie.

app.post("/logout", (req, res) => {
  res.clearCookie("refreshToken", {
    httpOnly: true,
    secure: process.env.NODE_ENV === "production",
    sameSite: "strict",
  });
  res.json({ message: "Logged out successfully" });
});

⚠️ Security Best Practices

To ensure the highest level of security for your authentication system, consider these best practices:

  • Access Token Storage: Store access tokens in memory (e.g., a JavaScript variable) rather than localStorage or sessionStorage to mitigate Cross-Site Scripting (XSS) attacks.
  • Refresh Token Storage: Always store refresh tokens in HttpOnly cookies. This prevents client-side JavaScript from accessing the token, providing robust protection against XSS.
  • Refresh Token Rotation: Implement a strategy where a new refresh token is issued each time an existing one is used to generate a new access token. This limits the lifespan of a compromised refresh token.
  • Blacklisting/Invalidation: For critical security scenarios, consider blacklisting or invalidating old refresh tokens in a secure database (like Redis) upon rotation or logout to provide server-side control over token validity.
  • HTTPS: Always enforce HTTPS in production environments to encrypt all communication and prevent tokens from being intercepted over the network.

🎯 Conclusion

By intelligently combining JWT access tokens with refresh tokens, you can construct a robust and user-friendly authentication system that doesn’t compromise on security. This strategy effectively balances the need for frequent token expiration with the desire for a seamless user experience, making it a cornerstone of modern, scalable, and secure authentication architectures.

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.
You need to agree with the terms to proceed