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
orsessionStorage
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.