DEV Community

MongoDB Guests for MongoDB

Posted on

Advanced API Authentication Strategies with Node.js and MongoDB (Part 3)

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:

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,
};
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

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,
  });
};
Enter fullscreen mode Exit fullscreen mode

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",
});
Enter fullscreen mode Exit fullscreen mode

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,
     });

Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
    },
  });
};
Enter fullscreen mode Exit fullscreen mode

Also, update the export block to contain the getMe method we just added, like so

module.exports = {
  register,
  login,
  setupMFA,
  verifyMFA,
  verifyMFALogin,
  getMe,
};
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode

Then add the route below to the route on the page.

router.get("/me", requireAuth, getMe);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add the following to the body

{ 
  "email": "test_user@example.com",
  "password": "password123"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Also, add the following to the body of the request:

{ "token": "TOKEN_GENERATED_BY_AUTH_APP",
  "mfaToken": "MFA_TOKEN_GENERATED_FROM_LOGIN"
}
Enter fullscreen mode Exit fullscreen mode

Protected route

Next, send a GET request to the route below

GET /api/auth/me
Enter fullscreen mode Exit fullscreen mode

With the following Header values

Authorization: Bearer YOUR_ACCESS_TOKEN
Enter fullscreen mode Exit fullscreen mode

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",
     });
   }
 };
Enter fullscreen mode Exit fullscreen mode

Next, let's update the exports with the freshAccesstoken method like so:

module.exports = {
  register,
  login,
  setupMFA,
  verifyMFA,
  verifyMFALogin,
  getMe,
  refreshAccessToken,
};
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

Let's proceed to test. Send a POST request to the route below.

POST /api/auth/refresh
Enter fullscreen mode Exit fullscreen mode

Request body:

{
  "refreshToken": "YOUR_REFRESH_TOKEN"
}
Enter fullscreen mode Exit fullscreen mode

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",
     });
   }
 };
Enter fullscreen mode Exit fullscreen mode

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(),
}
);
Enter fullscreen mode Exit fullscreen mode

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",
     });
   }
 };
Enter fullscreen mode Exit fullscreen mode

Update the export block to contain all the methods below

module.exports = {
  register,
  login,
  setupMFA,
  verifyMFA,
  verifyMFALogin,
  getMe,
  refreshAccessToken,
  logout,
  logoutAll,
};
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Request body:

{
  "refreshToken": "YOUR_REFRESH_TOKEN"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Also, add a header with a user's access token.

Authorization: Bearer YOUR_ACCESS_TOKEN
Enter fullscreen mode Exit fullscreen mode

Expected response:

{
  "success": true,
  "message":
    "Logged out from all devices"
}
Enter fullscreen mode Exit fullscreen mode

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)