When you’re building authentication in a Node.js application, JSON Web Tokens (JWTs) are one of the most common approaches. They’re fast, stateless, and easy to work with. But they also come with a catch: expiration. If you issue a short-lived token (say, 15 minutes), users get logged out too often. If you issue a long-lived token (say, 7 days), it increases security risks if the token leaks.
That’s where refresh tokens come in.
We’ll 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?
A JWT access token is used to authenticate requests. It’s short-lived, usually 10–30 minutes, to minimize risk.
A refresh token is a long-lived credential (days or weeks) that lets the client request a new access token without logging in again.
- Access Token → Fast, stateless, expires quickly.
- Refresh Token → Long-lived, securely stored, used only to renew access tokens.
🛠️ Project Setup
Let’s spin up a minimal Express.js app with JWT authentication and refresh tokens.
1. Install Dependencies
npm init -y
npm install express jsonwebtoken bcryptjs cookie-parser dotenv
-
express
→ Web framework -
jsonwebtoken
→ For signing/verifying JWTs -
bcryptjs
→ For password hashing -
cookie-parser
→ To handle cookies (where refresh tokens can be stored) -
dotenv
→ Manage environment variables
⚙️ Basic Server Setup
// 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
We’ll need two token generators:
-
generateAccessToken(user)
→ short-lived (e.g., 15m) -
generateRefreshToken(user)
→ long-lived (e.g., 7d)
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
When a user logs in, we issue both tokens:
const users = []; // mock DB
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
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true, // enable in production (https)
sameSite: "strict",
});
res.json({ accessToken });
});
🔄 Refreshing Tokens
Now, we add a /refresh
endpoint to issue a new access token when the old one expires.
app.post("/refresh", (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).json({ error: "No token provided" });
jwt.verify(token, process.env.REFRESH_SECRET, (err, user) => {
if (err) return res.status(403).json({ error: "Invalid token" });
const newAccessToken = generateAccessToken(user);
res.json({ accessToken: newAccessToken });
});
});
🔒 Middleware to Protect Routes
function authMiddleware(req, res, next) {
const authHeader = req.headers["authorization"];
const token = authHeader && authHeader.split(" ")[1];
if (!token) return res.sendStatus(401);
jwt.verify(token, process.env.ACCESS_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
req.user = user;
next();
});
}
app.get("/protected", authMiddleware, (req, res) => {
res.json({ message: "You are authorized!", user: req.user });
});
🚪 Logout
To log out, just clear the refresh token.
app.post("/logout", (req, res) => {
res.clearCookie("refreshToken");
res.json({ message: "Logged out" });
});
⚠️ Security Best Practices
- Store access token in memory (not localStorage/sessionStorage).
- Store refresh token in HttpOnly cookie → protects against XSS.
- Rotate refresh tokens → issue a new refresh token each time it’s used.
- Blacklist old refresh tokens if you want full control (store them in Redis or DB).
- Use HTTPS in production to prevent token leaks.
🔄 Refreshing Tokens - Analogy
Here’s how it maps to frontend-backend requests:
- Login:
- Frontend sends your username/password.
-
Backend checks credentials and gives you:
- A short-lived access token (movie ticket)
- A long-lived refresh token (season pass, stored in an HttpOnly cookie).
- Accessing protected routes:
- Frontend attaches the access token in the request headers.
- Backend verifies it. If valid → allows access.
- Access token expires:
- Frontend tries to make a request → gets
401 Unauthorized
. - Frontend then calls
/refresh
without sending username/password, only letting the browser send the refresh token cookie automatically.
- Backend verifies refresh token:
- If valid → issues a new access token (new movie ticket).
- If invalid/expired → user must log in again (season pass expired).
Key point:
The frontend never has direct access to the refresh token if you store it in an HttpOnly cookie. It just “trusts the browser” to send it along with /refresh
requests. This keeps it safe from malicious scripts.
🎯 Conclusion
Using refresh tokens with JWTs balances security and usability. You get short-lived access tokens for protection and long-lived refresh tokens for convenience. In production, always handle refresh token storage carefully, implement rotation, and combine with HTTPS for a secure system.
This setup is scalable, works well with stateless services, and is widely used in modern authentication systems.
Top comments (0)
Some comments may only be visible to logged-in visitors. Sign in to view all comments.