This article was written by Moses Anumadu.
Secure Session Management with JWT and Refresh Tokens in Node.js and MongoDB
This is the final part of this 3-part series. In parts 1 and 2, we implemented email-based authentication. Then we went further to implement TOTP-based multi-factor authentication (MFA), QR code onboarding, and half-authentication login flow.
What we have built so far is an API that allows users to prove their identity using email and password. In this section, we will go beyond that. The application should not only be able to recognize users but also protect the user through concepts like secure session management, token expiration, and session revocation. We will implement these concepts through the following:
- JWT access tokens
- refresh tokens
- MongoDB-backed sessions
- refresh token rotation
- protected routes
- secure logout flows
By the end of this section, we should have an authentication system that can be deployed to production and improved upon for more advanced features.
Pre-requisites
To follow along with this tutorial, ensure you have prior experience working with the following technologies:
- Node.js and Express
- MongoDB and Mongoose
- A MongoDB Atlas account
- Postman
- Ngrok
- Parts one and two of this series
With that said, let's get started.
Understanding Access Tokens vs Refresh Tokens
An access token is a JWT token that grants a user access to a protected route. It often has a very short-lived life cycle. A refresh token is a token with a longer expiration time solely for requesting an access token. Without the refresh token, the user would need to log out and log back in to generate a new access token. The refresh token is stored in the database, enabling the system to recognize the user without terminating the session.
You are probably wondering why you need both, right? Let's look at an example. Imagine that a hacker steals a user's access token, and with this token, he (the hacker) can do malicious things. If this access token does not have a short-lived life cycle, the attacker would have a longer window to do more harm. To prevent this, the refresh token is required to generate a new access token from time to time. So, even if an access token is stolen, within 15 minutes, that token becomes invalid. The attacker can't do any harm.
This is an extra layer of security necessary for a secure authentication system and to improve the user experience. The flow looks like: a user logs in, access token and refresh token are granted, access token lasts for 15 minutes, refresh token lasts for 7 days, access token expires after 15 minutes, user does not need to log out, user generates a new access token using the refresh token saved in the database.
Creating Token Utilities
Now, we understand why we need both an access token and a refresh token. Let's proceed to the implementation. In part 2, when we created the MFA token, we created src/utils/tokenUtils.js file. We need to update that file to generate our access token, refresh token, and MFA token. Open src/utils/tokenUtils.js and replace the code with the code below:
const jwt = require("jsonwebtoken");
const crypto = require("crypto");
const generateAccessToken = (user) => {
return jwt.sign(
{
userId: user._id,
email: user.email,
},
process.env.JWT_ACCESS_SECRET,
{
expiresIn: process.env.ACCESS_TOKEN_EXPIRES_IN,
}
);
};
const generateRefreshToken = () => {
return crypto.randomBytes(64).toString("hex");
};
const hashToken = (token) => {
return crypto
.createHash("sha256")
.update(token)
.digest("hex");
};
const generateMFAToken = (user) => {
return jwt.sign(
{
userId: user._id,
type: "mfa",
},
process.env.JWT_ACCESS_SECRET,
{
expiresIn: process.env.MFA_TOKEN_EXPIRES_IN,
}
);
};
module.exports = {
generateAccessToken,
generateRefreshToken,
hashToken,
generateMFAToken,
};
We added three methods generateAccessToken, generateRefreshToken, hashToken to the page. The hashToken will be used later. It will be used to encrypt our refresh token into a one-way SHA-256 hash before it is stored in the database or compared when validating. The generateAccessToken and generateRefreshToken generate an access token and a refresh token, respectively.
Why Refresh Tokens Are Not JWTs
Also, did you notice that our refresh tokens were generated using secure random bytes? This line of code here: crypto.randomBytes(64).toString("hex");. This simply gives us more control over it. The refresh token should be random, unpredictable, and most importantly, database-controlled. Refresh tokens, unlike access tokens, do not need readable claims.
So, this way, we can easily revoke it, rotate it, and control the session validation or invalidation.
Creating the Session Model
Our refresh token is database-controlled. That ultimately means we need to store and retrieve it from the database. To achieve this, we need to create a MongoDB model for storing user sessions. Create src/models/Session.js and update the page with the code below:
const mongoose = require("mongoose");
const sessionSchema = new mongoose.Schema(
{
userId: {
type: mongoose.Schema.Types.ObjectId,
ref: "User",
required: true,
},
refreshTokenHash: {
type: String,
required: true,
},
userAgent: {
type: String,
default: null,
},
ipAddress: {
type: String,
default: null,
},
expiresAt: {
type: Date,
required: true,
},
revokedAt: {
type: Date,
default: null,
},
},
{
timestamps: true,
}
);
module.exports = mongoose.model("Session", sessionSchema);
Notice that we are storing refreshTokenHash. Always remember that sensitive data at rest should also be encrypted. We created the fields we need to save for each user session. Notice fields like ipAddress, userAgent, expiresAt and revokedAt.
Issuing Tokens After MFA Verification
Now we can generate both a refresh token and an access token, and our MongoDB model is ready to save session values in the database. Next, we need to modify the authentication flow to include these. The flow should be: a user logs in, completes MFA, the application issues an access token and a refresh token to the user, and finally, the session values (including the refresh token) are stored in our MongoDB session collection. Let's proceed to the implementation.
!!!
Returning refresh tokens in JSON bodies is unsafe for browser clients and encourages XSS-exposed storage patterns. The safer pattern is HTTP-only cookie transport for refresh credentials. This is the approach we will follow in the next section.
!!!
Updating the Login Controller
In the next section, we will send refresh tokens using HTTP-only cookies transport as this is the safer option to prevent XSS-expose attacks.
The code in src/controllers/authController.js needs to be updated. Let's start by adding the import values and HTTP-only cookies configurations.
Open src/controllers/authController.js and add the following import statement to the top part of the page
const jwt = require("jsonwebtoken");
const Session = require("../models/Session");
const {
generateAccessToken,
generateRefreshToken,
hashToken,
generateMFAToken,
} = require("../utils/tokenUtils");
After the import statements, add the following HTTP-only cookie configurations. We will use these later in the code.
const getRefreshTokenExpiryMs = () => {
const refreshDays = Number(
process.env.REFRESH_TOKEN_EXPIRES_DAYS
);
return refreshDays * 24 * 60 * 60 * 1000;
};
const getRefreshTokenCookieOptions = () => {
return {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
path: "/api/auth",
maxAge: getRefreshTokenExpiryMs(),
};
};
const setRefreshTokenCookie = (res, refreshToken) => {
res.cookie(
"refreshToken",
refreshToken,
getRefreshTokenCookieOptions()
);
};
const clearRefreshTokenCookie = (res) => {
res.clearCookie("refreshToken", {
...getRefreshTokenCookieOptions(),
maxAge: undefined,
});
};
Updating the MFA Login Verification Flow
Next, replace the success section inside verifyMFALogin controller method with the code below:
Revoke this section
return res.status(200).json({
success: true,
message: "MFA login successful",
});
And replace with this:
// Issue access token
const accessToken = generateAccessToken(user);
// Generate refresh token
const refreshToken =
generateRefreshToken();
// Hash refresh token
const refreshTokenHash =
hashToken(refreshToken);
// Session expiry
const expiresAt = new Date();
expiresAt.setDate(
expiresAt.getDate() +
Number(
process.env.REFRESH_TOKEN_EXPIRES_DAYS
)
);
// Store session
await Session.create({
userId: user._id,
refreshTokenHash,
userAgent: req.headers["user-agent"],
ipAddress: req.ip,
expiresAt,
});
setRefreshTokenCookie(res, refreshToken);
return res.status(200).json({
success: true,
message: "Login successful",
accessToken,
});
From the additions we made, after password verification and MFA verification, a token is issued, and a session is created for the authenticated user.
Creating JWT Authentication Middleware
Now, we can issue an access token and a refresh token. Next, we need a way to protect our routes so that only users with the right access can access them. To do this, we need to implement middlewares.
A good example can be creating a middleware that protects routes and lets only authenticated users have access to it. Let's implement that. Create the following file src/middleware/authMiddleware.js, and update it with the code below:
const jwt = require("jsonwebtoken");
const User = require("../models/User");
const requireAuth = async (
req,
res,
next
) => {
try {
const authHeader =
req.headers.authorization;
if (
!authHeader ||
!authHeader.startsWith("Bearer ")
) {
return res.status(401).json({
success: false,
message: "Access token required",
});
}
const token = authHeader.split(" ")[1];
const decoded = jwt.verify(
token,
process.env.JWT_ACCESS_SECRET
);
const user = await User.findById(
decoded.userId
);
if (!user) {
return res.status(401).json({
success: false,
message: "Invalid user",
});
}
req.user = user;
next();
} catch (error) {
return res.status(401).json({
success: false,
message:
"Invalid or expired access token",
});
}
};
module.exports = requireAuth;
Creating a Protected Route
At this point, the middleware is ready. We need to attach it to our routes to restrict access to only authenticated users. Let's create a controller to get the authenticated user getMe and a route for it /api/auth/me then protect it with the middleware we created above. To create the controller method, open src/controllers/authController.js and add the following controller method to the page.
const getMe = async (req, res) => {
return res.status(200).json({
success: true,
user: {
id: req.user._id,
email: req.user.email,
mfaEnabled: req.user.mfaEnabled,
},
});
};
Also, update the export block to contain the getMe method we just added, like so
module.exports = {
register,
login,
setupMFA,
verifyMFA,
verifyMFALogin,
getMe,
};
Adding the Protected Route
Next, let's create the route. Open src/routes/authRoutes.js and add the following to the page.
First, import the middleware by copying and pasting the code below to the top section of the page
const { getMe } = require("../controllers/authController");
const requireAuth = require("../middleware/authMiddleware");
Then add the route below to the route on the page.
router.get("/me", requireAuth, getMe);
Testing Protected Routes
We've made progress. Let's test what we've built so far. We will test with Postman on the web and Ngrok. Ngrok will be used to tunnel our localhost address to something on the internet. Check Part One of this article for more on the setup. With that said,
Login
To generate an access token, we need to go through a series of verifications. First, we need to log in and generate an MFA token to verify MFA. Send a post request to the route below to log in.
POST /api/auth/login
Add the following to the body
{
"email": "test_user@example.com",
"password": "password123"
}
Your response should look similar to the image below if everything is done right.
MFA verification and access key generation
We are partially logged in. We need to verify MFA to be fully logged in. Send a Post request to the route below:
POST /api/auth/mfa/login/verify
Also, add the following to the body of the request:
{ "token": "TOKEN_GENERATED_BY_AUTH_APP",
"mfaToken": "MFA_TOKEN_GENERATED_FROM_LOGIN"
}
Protected route
Next, send a GET request to the route below
GET /api/auth/me
With the following Header values
Authorization: Bearer YOUR_ACCESS_TOKEN
If the token is valid, the request should return the authenticated user information, like the image below
Also, in MongoDB Atlas, you can see that user sessions were created and stored in our session collection.
Implementing Refresh Token Rotation
Let's take our authentication a step further by implementing refresh token rotations. What we want to achieve is simple. Every refresh request should generate a new access token, generate a new refresh token, and invalidate the old refresh tokens. Let's jump right into it.
Creating the Refresh Endpoint
Add the following controller method to src/controllers/authController.js.
const refreshAccessToken = async (req, res) => {
try {
const refreshToken = req.cookies?.refreshToken;
if (!refreshToken) {
return res.status(401).json({
success: false,
message: "Refresh token cookie required",
});
}
const refreshTokenHash = hashToken(refreshToken);
const session = await Session.findOne({
refreshTokenHash,
revokedAt: null,
});
if (!session) {
clearRefreshTokenCookie(res);
return res.status(401).json({
success: false,
message: "Invalid session",
});
}
// Expired session
if (session.expiresAt < new Date()) {
clearRefreshTokenCookie(res);
return res.status(401).json({
success: false,
message: "Session expired",
});
}
const user = await User.findById(session.userId);
if (!user) {
return res.status(401).json({
success: false,
message: "Invalid user",
});
}
// ROTATE REFRESH TOKEN
const newRefreshToken = generateRefreshToken();
const newRefreshTokenHash = hashToken(newRefreshToken);
session.refreshTokenHash = newRefreshTokenHash;
await session.save();
setRefreshTokenCookie(res, newRefreshToken);
// Generate new access token
const accessToken = generateAccessToken(user);
return res.status(200).json({
success: true,
accessToken,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to refresh token",
});
}
};
Next, let's update the exports with the freshAccesstoken method like so:
module.exports = {
register,
login,
setupMFA,
verifyMFA,
verifyMFALogin,
getMe,
refreshAccessToken,
};
Adding the Refresh Route and Testing
Let's proceed by adding a route to test the refresh token feature. Open src/routes/authRoutes.js and update the page with the code below
router.post(
"/refresh",
refreshAccessToken
);
Let's proceed to test. Send a POST request to the route below.
POST /api/auth/refresh
Request body:
{
"refreshToken": "YOUR_REFRESH_TOKEN"
}
If everything was done right, your response should look like the screenshot above. And the previous tokens should no longer work.
Implementing Logout and Session Revocation
A complete logout system not only removes the access token from the browser in the front end. It should also invalidate the refresh token and revoke the session from the backend. That is exactly what we will do in this section.
Creating the Logout Endpoint
Add the following controller method to src/controllers/authController.js
const logout = async (req, res) => {
try {
const refreshToken = req.cookies?.refreshToken;
if (!refreshToken) {
return res.status(400).json({
success: false,
message: "Refresh token cookie required",
});
}
const refreshTokenHash = hashToken(refreshToken);
await Session.findOneAndUpdate(
{
refreshTokenHash,
revokedAt: null,
},
{
revokedAt: new Date(),
}
);
clearRefreshTokenCookie(res);
return res.status(200).json({
success: true,
message: "Logged out successfully",
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Logout failed",
});
}
};
From the code, we did not delete the session records. We marked them as revoked instead, as we can see below.
await Session.findOneAndUpdate(
{
refreshTokenHash,
revokedAt: null,
},
{
revokedAt: new Date(),
}
);
This is a good practice as it helps preserve session history for audit and security visibility.
Implementing Logout From All Devices
Let's take it a step further by allowing users to revoke all active sessions simultaneously. This would let them log out from all devices at once. This is a nice feature to have; it helps a user protect their account in the case of a malicious login from an unknown device.
Let's proceed, modify src/controllers/authController.js file, and add the following controller method:
const logoutAll = async (req, res) => {
try {
await Session.updateMany(
{
userId: req.user._id,
revokedAt: null,
},
{
revokedAt: new Date(),
}
);
clearRefreshTokenCookie(res);
return res.status(200).json({
success: true,
message: "Logged out from all devices",
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to logout all sessions",
});
}
};
Update the export block to contain all the methods below
module.exports = {
register,
login,
setupMFA,
verifyMFA,
verifyMFALogin,
getMe,
refreshAccessToken,
logout,
logoutAll,
};
Adding Logout Routes
To add the routes for logout, update src/routes/authRoutes.js and add the code below. Also ensure that you have imported both logout and logoutAll methods from authController.js.
router.post("/logout", logout);
router.post(
"/logout-all",
requireAuth,
logoutAll
);
Testing Logout
Send a post request to the route below. Also, pass the body value in the body of your request.
Request:
POST /api/auth/logout
Request body:
{
"refreshToken": "YOUR_REFRESH_TOKEN"
}
Your response should look similar to the screenshot below
You can also test the logout all feature the same way. Send a Post request to the route below with a valid access token.
Request:
POST /api/auth/logout-all
Also, add a header with a user's access token.
Authorization: Bearer YOUR_ACCESS_TOKEN
Expected response:
{
"success": true,
"message":
"Logged out from all devices"
}
Did you notice that the logout-all requires an access token passed in as a request header, not a refresh token like the logout route? This is because the logout-all does not have an active user session or know the user sending the request. It needs a way to identify the user, so it requires a user's access token.
All refresh token sessions should now become invalid. To be sure, you can also try using the same refresh token. It should fail because this session has been revoked.
Conclusion
A big thumbs up to you for sticking around till the end of this three-part series. We built a complete authentication API using Node.js, Express, MongoDB Atlas, and tested using Postman on the web and Ngrok for localhost routing.
In this final part of this series (part three), we discussed the difference between JWT access tokens and refresh tokens, why we need both, and walked through the programmatic implementation of the authentication by implementing the following:
- JWT access token authentication
- Session storage
- refresh token rotation
- Middleware and protected routes
- secure logout and session revocation
The complete flow of the authentication with all three (3) parts put together looks like:
User registration, password authentication, MFA setup and verification, JWT access tokens and refresh token issuance, refresh token sessions, middlewares and protected routes, session.
Security is more of layers than single features. For further reading, check out the following topics for additional layers that can be added to an authentication.
- Rate limiting
- Email verification
- HTTPS enforcement
- Secure HTTP-only cookies
- Brute-force protection
- Device monitoring
- Anomaly detection
Awesome. You can find the complete code on GitHub.





Top comments (0)