Implementing MFA and TOTP with Node.js and MongoDB
This article was written by Moses Anumadu.
In part one (1) of this series, we implemented basic authentication. Users can register and also log in. In this section, we will take it a step further by adding multi-factor authentication (MFA) using a time-based one-time password (TOTP).
Basic authentication, which uses only a password and email, is arguably not secure enough for today's internet. Many users reuse their passwords, and attackers can exploit this vulnerability.
The core principle of multi-factor authentication is that it combines something the user knows with something the user owns to verify the user's identity. Something the User knows can be a password. Something the user owns can be a token sent to the user's device or email. The user needs to prove he knows the password and own the device where TOTP was sent.
If conditions are met, the user can be verified. With that said, let's get started on how to implement this in a Node.js and MongoDB application.
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
- Part one of this series
Adding MFA with TOTP
Let's go over what the flow of our MFA implementation would look like. So think of it like this:
First, the authenticated user has to enable MFA in the app. Next, the server generates a shared secret. Next, the user would need to scan a QR code with their phone camera or any of the authenticator apps of their choice (Google Authenticator, Authy, Microsoft Authenticator, etc). Next, the authenticator app would store the secret; the next app generates a 6-digit code. Finally, the server would verify the code. If correct, the user is authenticated.
The important thing to note here is that both the server and the authenticator app share the same secret. Using the current time and that secret, both sides independently generate matching one-time codes.
Creating a Temporary Authentication Middleware
One more step before we proceed. JWT authentication will be implemented in part three (3) of this tutorial. Since we have not implemented it yet, but need a way to identify the logged-in user, we will identify the user using the request header. This is temporary and will be updated to use JWT in part three. For now, create tempAuth.js in src/middleware/ directory. Update the file with the code below
const User = require("../models/User");
const tempAuth = async (req, res, next) => {
try {
const email = req.headers["x-user-email"];
if (!email) {
return res.status(401).json({
success: false,
message: "Missing x-user-email header",
});
}
const user = await User.findOne({ email });
if (!user) {
return res.status(401).json({
success: false,
message: "User not found",
});
}
req.user = user;
next();
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Authentication middleware error",
});
}
};
module.exports = tempAuth;
This middleware is only temporary and will later be replaced with proper JWT authentication.
Generating MFA Secrets and QR Codes
We now have a way to temporarily identify the user. Let's proceed to the MFA secrets and QR codes implementation. If you can recall, in the first part of this series, we installed all the NPM packages we would need. Two of them were Otplib and Qrcode. It is the package of choice for this implementation. If you do not have them installed already, you can install them using the NPM command below:
npm install otplib
And
npm install --save qrcode
Once you confirm you have them installed, let's import and use them in the authController.js. Open src/controllers/authController.js. Add the code below to the top section of the page.
const QRCode = require("qrcode");
const {
generateSecret,
generateURI,
verify,
} = require("otplib");
Next, add the code below after the login method on the page
const setupMFA = async (req, res) => {
try {
const user = req.user;
// Generate shared secret
const secret = generateSecret();
// Generate OTP auth URL
const otpAuthUrl = generateURI({
label: user.email,
issuer: "AdvancedAuthAPI",
secret,
});
// Generate QR code image
const qrCodeImageUrl =
await QRCode.toDataURL(otpAuthUrl);
// Store secret temporarily
user.mfaSecret = secret;
await user.save();
return res.status(200).json({
success: true,
message: "MFA setup initialized",
secret,
qrCodeImageUrl,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to setup MFA",
});
}
};
module.exports = { register, login, setupMFA};
The code is well commented. Regardless, let's still go over a few lines and what they do. This line generates the shared secret: const secret = generateSecret();
The generated secret is then embedded into a special OTP URI in the block of code below
const otpAuthUrl = generateURI({
label: user.email,
issuer: "AdvancedAuthAPI",
secret,
});
For the user to be able to scan, we converted the URI into a QR code using the qrcode package in the line of code below:
const qrCodeImageUrl =
await QRCode.toDataURL(otpAuthUrl);
When a user scans the QR code with their authenticator app, the authenticator app securely stores the shared secret.
Verifying MFA Codes
We have completed the MFA setup functionality. Let's proceed to the verification functionality. We need another controller method to verify the 6-digit code generated by the authenticator app. This will have a different endpoint also. Let's proceed. Still in src/controllers/authController.js add the following verification controller method below setupMFA:
const verifyMFA = async (req, res) => {
try {
const user = req.user;
const { token } = req.body;
if (!token) {
return res.status(400).json({
success: false,
message: "MFA token is required",
});
}
const verification = await verify({
token,
secret: user.mfaSecret,
});
const isValid = verification?.valid === true;
if (!isValid) {
return res.status(401).json({
success: false,
message: "Invalid MFA code",
});
}
user.mfaEnabled = true;
await user.save();
return res.status(200).json({
success: true,
message: "MFA enabled successfully",
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "Failed to verify MFA",
});
}
};
module.exports = { register, login, setupMFA, verifyMFA };
The token is passed along as req.body. When received in the code, it is verified using
const verification = await verify({
token,
secret: user.mfaSecret,
});
If the code is valid, the user is logged in, and MFA is enabled. Did you notice that the MFA is only enabled after a successful verification? user.mfaEnabled = true;. This was done to prevent a broken MFA setup, which can accidentally lock a user out of their account. This prevents broken MFA setups and accidental account lockouts.
Adding MFA Routes
We need to update our authRoutes.js to include routes for both the setupMFA and verifyMFA controller methods. To do this, open src/routes/authRoutes.js and replace the content of the page with the code below
const express = require("express");
const {
register,
login,
setupMFA,
verifyMFA,
} = require("../controllers/authController");
const tempAuth = require("../middleware/tempAuth");
const router = express.Router();
router.post("/register", register);
router.post("/login", login);
router.post("/mfa/setup", tempAuth, setupMFA);
router.post("/mfa/verify", tempAuth, verifyMFA);
module.exports = router;
Testing MFA Setup
In part one (1) of this series, we set up Ngrok for tunneling our local host to the internet and tested our endpoints using Postman. If you do not have that setup, follow the link to set up to follow along.
If your project is not running already, start it using the command below
npm run dev
Then, send a POST request to the following endpoint:
POST /api/auth/mfa/setup
Remember our temporary middleware, set the header value to the value below
Headers:
x-user-email: test_user@example.com
The response should include a QR code image URL. Check out the images below for more details

Copy the URL from the response and paste it into a browser. You should see a QR code image, like what we have below
Next, scan the QR code using any authenticator app of your choice. After scanning, your authenticator app should begin generating 6-digit codes like the image below.
Testing MFA Verification
Let's proceed to test the MFA verification endpoint. Send a post request to the route below
POST /api/auth/mfa/verify
Also, send the following header values:
x-user-email: test_user@example.com
Then, the request body should contain the following:
{
"token": "MFA_app_generated_code"
}
Don't forget to replace the token placeholder in the request body with the correct code from your authenticator app.
The images below should throw more light on the steps.
Implementing the Half-Authenticated Flow
Our MFA setup and verification now work. However, it is completely independent of the login and sign-up. We have created routes for the MFA but have not linked it to the login and sign-up flow. What we have now gives a user full authentication once a correct email and password are provided. We need to change that. We need a partial authentication flow: this is what I mean, when a user provides a correct email and password, we need to partially identify the user, and yet, prompt for MFA. Only when MFA is completed can the user be fully authenticated.
To achieve this, we will issue a temporary JWT token to the user after a correct password and email are provided; at this state the user is not yet authenticated but can be identified with this JWT token. Then, when the MFA process is completed, only then will the user be fully authenticated.
When we are done, the flow should be: password verified, issue a temporary JWT token to identify the user, MFA code generated from the authenticator app, the user submits the OTP code, and finally, authentication is completed. Let's proceed.
Creating MFA Tokens
Let's write the code to generate the temporary JWT token for the MFA process. Create src/utils/tokenUtils.js the following file and update it with the code below
const jwt = require("jsonwebtoken");
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 = {
generateMFAToken,
};
In the code above, we imported require("jsonwebtoken"); and used it to generate the JWT token. Token expiration and secret values come from the env file created earlier in part one of this series.
Updating the Login Flow
We need to update the login controller method to add the generated MFA JWT token. To continue, open src/controllers/authController.js and add the following.
First, add the import below to the top section of the page.
const jwt = require("jsonwebtoken");
const {
generateMFAToken,
} = require("../utils/tokenUtils");
Next, let's update the login controller with the code below. Paste the code just above the return statement.
// MFA enabled
if (user.mfaEnabled) {
const mfaToken = generateMFAToken(user);
return res.status(200).json({
success: true,
requiresMFA: true,
mfaToken,
});
}
Now, users with MFA enabled no longer receive full authentication immediately after password verification. Instead, they receive a temporary MFA token.
Completing MFA Login
We need another controller method to process the MFA verification. Add the following controller below verifyMFA method:
const verifyMFALogin = async (req, res) => {
try {
const { token, mfaToken } = req.body;
if (!token || !mfaToken) {
return res.status(400).json({
success: false,
message:
"Token and MFA token are required",
});
}
// Verify temporary MFA token
const decoded = jwt.verify(
mfaToken,
process.env.JWT_ACCESS_SECRET
);
if (decoded.type !== "mfa") {
return res.status(401).json({
success: false,
message: "Invalid MFA session",
});
}
const user = await User.findById(
decoded.userId
);
if (!user || !user.mfaEnabled) {
return res.status(401).json({
success: false,
message: "Invalid user",
});
}
// Verify OTP
const verification = await verify({
token,
secret: user.mfaSecret,
});
const isValid =
verification?.valid === true;
if (!isValid) {
return res.status(401).json({
success: false,
message: "Invalid MFA code",
});
}
return res.status(200).json({
success: true,
message: "MFA login successful",
});
} catch (error) {
console.error(error);
return res.status(401).json({
success: false,
message:
"Invalid or expired MFA session",
});
}
};
Also, don't forget to update the export block with the code below.
module.exports = {
register,
login,
setupMFA,
verifyMFA,
verifyMFALogin,
};
Adding the MFA Login Verification Route
Let's create routes for the above changes. Open src/routes/authRoutes.js and update the page with the code below:
router.post(
"/mfa/login/verify",
verifyMFALogin
);
Testing the Full MFA Login Flow
The first step is to log in normally. Send a Post request to the route below
POST /api/auth/login
If MFA is enabled, the response should contain an mfaToken just like the image below:
Next, verify MFA by sending a Post request to the route below
POST /api/auth/mfa/login/verify
The request should pass the code below in the body.
Request body:
{
"token": "123456",
"mfaToken": "..."
}
After testing, your response should look similar to the image below.
Conclusion
In this section, we added an extra layer on top of the existing authentication system we built in part one. After entering the correct email and password, users must configure and verify MFA using TOTP. To achieve this, we implemented the following in the code:
- login verification
- TOTP-based MFA
- QR code onboarding
- half-authenticated login flows
We've gone halfway, but we're not done. In Part 3, we will take it a step further by adding
- JWT access tokens
- refresh tokens
- MongoDB-backed sessions
- refresh token rotation
- protected routes
- secure logout and session revocation
See you in part 3. Cheers.





Top comments (0)