This article will teach you how to secure a Node.js API by implementing two-factor authentication (2FA) system using tokens generated by Google Authenticator or Authy. The one-time passcode (OTP) can be delivered via different methods like SMS but we will use Google Authenticator or Authy to reduce the complexity of the project.

API security should be a top priority for developers. Why? Because there are more than 90,000 attacks on APIs every minute. Instead of using only the traditional method of authentication – email and password, it’s recommended to add an extra layer of security by implementing social login with Google, GitHub, or Facebook, Two-Factor Authentication (2FA), Multi-factor, Facial recognition, Biometrics, Single sign-on, and many more.

Let’s talk about the main focus of this article. We will be using the otpauth library to implement the time-based one-time password (TOTP) authentication in Node.js. The API will run on an Express server and use Prisma ORM to store data in an SQLite database.

When I initially wrote this article, we utilized the Speakeasy library to implement TOTP generation and validation. However, due to the lack of recent updates and maintenance for Speakeasy (last updated 7 years ago), many users have expressed a need for an alternative library.

Consequently, I updated the article and the accompanying source code to utilize the otpauth library instead. This library has gained popularity as a reliable alternative, with a substantial number of weekly downloads, approximately 63,207. By making this switch, we can ensure a more dependable and actively supported solution for TOTP implementation.

Related article:

More practice:

How to Implement (2FA) Two-factor Authentication in Node.js

What is Two-Factor Authentication?

Two-Factor Authentication, also commonly referred to as 2FA, is a specific type of multi-factor authentication (MFA) that requires two forms of identification (also referred to as authentication factors) to grant access to a resource or data. These factors include the traditional method of authentication like username/email and password – plus something you have – like a smartphone – to approve the requests.

Businesses use two-factor authentication (2FA) to protect against many security threats that target user accounts and passwords like credential exploitation, social engineering, password brute-force attacks, phishing, and many more.

There are many authentication methods when using two-factor authentication (2FA). Some of the most popular options include:

  • SMS verification
  • Voice-based authentication
  • Hardware tokens
  • Push notifications

Advantages of Two-Factor Authentication (2FA)

The advantages of using two-factor authentication are endless and am only going to list four of them.

  • It adds an extra layer of security by protecting against cybercriminals who target user passwords and accounts.
  • It does not add extra costs on the part of the user since most websites and apps use your mobile device to text, call, or use personalized 2FA methods to verify your identity.
  • Setting up 2FA is relatively easy and user-friendly. In most cases, all that the user will do is enable the 2FA feature and scan a QR code or enter the phone number to view or receive OTP codes.

Prerequisites

To get the most out of this tutorial, you should have:

  • Basic knowledge of JavaScript and TypeScript
  • Basic knowledge of API design and CRUD patterns
  • Familiarity with Node.js and Express.js will be beneficial
  • The latest version of Node.js installed on your system

Run the Node.js 2FA App Locally

  1. If you don’t have Node.js installed, visit https://nodejs.org/ to download the respective binary for your operating system.
  2. Download or clone the Node.js two-factor authentication source code from https://github.com/wpcodevo/2fa-nodejs
  3. Run yarn or yarn install to install all the necessary dependencies
  4. Migrate the Prisma schema to the SQLite database by running yarn db:migrate and yarn db:push .
  5. Start the Node.js Express server by running yarn start
  6. Open any API testing software like Postman to test the API endpoints

Run the Frontend Built with React.js

  • Download or clone the React.js two-factor authentication source code from https://github.com/wpcodevo/two_factor_reactjs.
  • Install all the necessary dependencies by running yarn or yarn install from the terminal of the root project folder.
  • Start the Vite development server by running yarn dev .
  • Open a new tab in your browser and visit http://localhost:3000 to start testing the two-factor authentication system.

Two-factor Authentication in Node.js Flow

The Node.js API will have the following endpoints:

METHODENDPOINTDESCRIPTION
POST/api/auth/registerRegister New User
POST/api/auth/loginLogin User
POST/api/auth/otp/generateGenerate the OTP Secret
POST/api/auth/otp/verifyVerify the OTP Secret
POST/api/auth/otp/validateValidate the OTP Token
POST/api/auth/otp/disableDisable the OTP Feature

The API endpoints can be tested with any API testing tool like Postman, Insomnia, VS Code Thunder Client extension, and more – but since we humans understand concepts better with visual demonstrations, we will use a frontend app built with React.js to interact with the Node.js API.

Setup the 2FA feature

To reduce the steps involved in the 2FA process, I will skip the account registration and login steps. However, you need to create an account and log in with your credentials before you can access the profile page.

When you click on the Setup 2FA button on the profile page, an HTTP POST request will be fired to the /api/auth/otp/generate endpoint where the Node.js API will generate the one-time passcode and return the OTP URL to the frontend app.

nodejs 2fa authenticate

Scan the QRCode

The React app will then generate the QRCode from the OTP URL sent by the Node.js API and render a component to display the QRCode.

nodejs enable the 2fa feature

Verify the OTP token

To see the OTP token, you can do the following:

In my case, I will use the Chrome Authenticator extension to scan the QR Code since am in a development environment.

To view the OTP token with the Chrome Authenticator extension, click on the Scan QR Code icon adjacent to the pencil icon, and drag the scanner over the QR Code.

After that, go back to the Chrome Authenticator extension and you should see the OTP token.

nodejs scanned the otp qrcode

Click on the OTP token to copy and paste it into the input field on the modal. When the Verify & Activate button is clicked, React will make an HTTP POST request to the /api/auth/otp/verify endpoint where the Node.js API will enable the 2FA feature and return a success message to the frontend app.

Validate the OTP token

After enabling the 2FA feature, refresh the page to log out. When you successfully log in with your credentials, React will automatically redirect you to the OTP verification page where you will be required to provide the OTP token to verify your identity.

nodejs verify the 2fa otp token

Disable the 2FA Feature

To disable the two-factor authentication (2FA) feature on your account, you can click the Disable 2FA button available on the profile page. Once the Disable 2FA button is clicked, React will make an HTTP POST request to the /api/auth/otp/disable endpoint.

One success, the 2FA feature will be disabled and the user will not be presented with the 2FA verification page during the authentication process.

nodejs 2fa enabled

Step 1 – Setup the Node.js Project

To begin, navigate to where you would like to set up the project and create a new directory to contain the source code. You can name the project 2fa_NodeJs . Once you are done, open the project with an IDE or text editor. In my case, I will be using VS Code.


mkdir 2fa_nodejs && cd 2fa_nodejs && code .

Next, you need to initialize the Node.js project with Yarn or NPM to manage the dependencies. Also, we will use TypeScript in this project to help us get better IntelliSense in VS Code.


yarn init -y && yarn add -D typescript @types/node
# or
npm init -y && npm install -D typescript @types/node

  • typescript – A superset of JavaScript
  • @types/node – Contains the TypeScript definitions for Node.js

This will create a package.json file with an initial setup for the Node.js TypeScript app. Once the project initialization is complete, you will have a package.json file that might look something like this:


{
  "name": "2fa_nodejs",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT"
}


Step 2 – Setup Prisma

The project will use Prisma ORM to store data in an SQLite database. What is Prisma? Prisma is a next-generation ORM for Node.js and TypeScript. It supports a lot of database servers like:

  • MongoDB
  • PostgreSQL
  • SQLite
  • SQL Server, and many more.

Since the main focus of this tutorial is how two-factor authentication (2FA) can be implemented in Node.js, we will use Prisma with SQLite because it only requires a few steps to get it up and running.

To get started, install these Prisma dependencies:


yarn add -D prisma && yarn add @prisma/client
# or
npm install -D prisma && npm install @prisma/client

  • prisma – A Prisma CLI that helps you interact with your Prisma project from the command line.
  • @prisma/client – An auto-generated query builder that enables type-safe database access.

After that, run the Prisma init command to initialize the Prisma assets in the project:


yarn prisma init --datasource-provider sqlite
# or 
npx prisma init --datasource-provider sqlite

  • --datasource-provider – this flag specifies the default database provider
  • sqlite – is the database

The above command will create a new prisma directory with your Prisma schema file and configure SQLite as your database. You’re now ready to define the data model and migrate the schema to the database.

Step 3 – Create the Prisma Database Model

The Prisma schema provides a simpler interface to model data. The model in the Prisma schema represents the SQL table in the underlying database.

Now open the prisma/schema.prisma file and replace its content with the following:

prisma/schema.prisma


generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model User {
  id       String @id @default(uuid())
  email    String @unique
  name     String
  password String

  otp_enabled  Boolean @default(false)
  otp_verified Boolean @default(false)

  otp_ascii    String?
  otp_hex      String?
  otp_base32   String?
  otp_auth_url String?

  @@map(name: "users")
}


In the above, we defined a User model with some fields. Each field has a field name followed by a datatype and optional field attributes. We used the @default(uuid()) attribute to generate a UUID for the id column instead of using incremental integers.

Now add the following commands to the package.json file:

package.json


{
  "name": "2fa_nodejs",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "ts-node-dev --respawn --transpile-only --exit-child server.ts",
    "db:migrate": "npx prisma migrate dev --name user-entity --create-only && npx prisma generate",
    "db:push": "npx prisma db push"
  },
  "devDependencies": {
    "@types/node": "^18.7.23",
    "prisma": "^4.4.0",
    "typescript": "^4.8.4"
  },
  "dependencies": {
    "@prisma/client": "^4.4.0"
  }
}

  • start – Starts the Node.js server
  • db:migrate – Generates the migration file and Prisma Client
  • db:push – Pushes the Prisma schema to the database

Step 4 – Database Migration with Prisma

At this point, you have a Prisma model but no database yet. Open your terminal and run the following command to create the SQLite database and the “users” table represented by the model.

Run this command to generate the database migration file and the Prisma Client:


npx prisma migrate dev --name user-entity --create-only && npx prisma generate

Push the Prisma schema to the database:


npx prisma db push

Alternatively, you can run yarn db:migrate and yarn db:push to execute the above commands.

After synching the SQLite database with the Prisma schema, run npx prisma studio to open the Prisma GUI tool in the browser. Prisma studio allows you to view and mutate the data stored in the database.

Step 5 – Setup the Node.js Express App

With all the above configurations, we are ready to set up the Node.js HTTP server. We will use the Express framework to set up the HTTP server and use SQLite for data storage.

Before the implementation, install the following dependencies:


yarn add express cors otpauth hi-base32 && yarn add -D morgan ts-node-dev @types/cors @types/express @types/morgan
# or 
npm install express cors otpauth hi-base32 && npm install -D morgan ts-node-dev @types/cors @types/express @types/morgan

  • express – A Node.js web framework
  • cors – Enable CORS in Express
  • otpauth – A library for generating and validating TOTP
  • hi-base32 – This library provides encoding and decoding functions for converting data to and from the Base32 format. We’ll use the base32-encoded string to generate the TOTP in an Authenticator app.
  • morgan – An HTTP request logger middleware for node.js
  • ts-node-dev – Run the Express.js server

After the installations, your package.json file should look like this:


{
  "name": "2fa_nodejs",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "ts-node-dev --respawn --transpile-only --exit-child server.ts",
    "db:migrate": "npx prisma migrate dev --name user-entity --create-only && npx prisma generate",
    "db:push": "npx prisma db push"
  },
  "devDependencies": {
    "@types/cors": "^2.8.12",
    "@types/express": "^4.17.14",
    "@types/morgan": "^1.9.3",
    "@types/node": "^18.7.23",
    "morgan": "^1.10.0",
    "prisma": "^4.4.0",
    "ts-node-dev": "^2.0.0",
    "typescript": "^4.8.4"
  },
  "dependencies": {
    "@prisma/client": "^4.4.0",
    "cors": "^2.8.5",
    "express": "^4.18.1",
    "hi-base32": "^0.5.1",
    "otpauth": "^9.1.2"
  }
}

With that out of the way, create a server.ts file and add the following code:

server.ts


import { PrismaClient } from "@prisma/client";
import express, { Request, Response } from "express";
import cors from "cors";
import morgan from "morgan";

export const prisma = new PrismaClient();
const app = express();

async function main() {
  // Middleware
  app.use(morgan("dev"));
  app.use(
    cors({
      origin: ["http://localhost:3000"],
      credentials: true,
    })
  );
  app.use(express.json());

  //   Health Checker
  app.get("/api/healthchecker", (req: Request, res: Response) => {
    res.status(200).json({
      status: "success",
      message: "Welcome to Two-Factor Authentication with Node.js",
    });
  });

  // Register the API Routes

  // Catch All
  app.all("*", (req: Request, res: Response) => {
    return res.status(404).json({
      status: "fail",
      message: `Route: ${req.originalUrl} not found`,
    });
  });

  const PORT = 8000;
  app.listen(PORT, () => {
    console.info(`Server started on port: ${PORT}`);
  });
}

main()
  .then(async () => {
    await prisma.$disconnect();
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });

Quite a lot is happening in the above, let’s break it down:

  • First, we created instances of the Prisma Client and Express. Next, we created a main function to contain all the server code.
  • The morgan("dev") middleware will configure Express to log the HTTP request information in the terminal.
  • The cors() middleware will configure the Express server to accept requests from cross-origin domains.
  • The express.json() middleware will parse the JSON payload of the incoming HTTP POST request and expose it as req.body.

Now start the Express.js server by running yarn start or


yarn ts-node-dev --respawn --transpile-only --exit-child server.ts
# or 
npx ts-node-dev --respawn --transpile-only --exit-child server.ts

This will start the Express server on port 8000. Open a new tab in the browser and navigate to http://localhost:8000/api/healthchecker. You should get the JSON response sent from the Express server.

two factor authentication 2fa express server initial set up

Step 6 – Create the Node.js Route Controllers

In this step, we will create six route controllers responsible for various tasks including user registration, login, generating the TOTP authentication URL and base32 string, verifying the TOTP, validating the TOTP, and disabling the TOTP. Prior to that, let’s begin by creating a new directory named controllers at the root level. Inside the newly created controllers directory, generate a file called auth.controller.ts and proceed to include the following import statements.

controllers/auth.controller.ts


import crypto from "crypto";
import { Prisma } from "@prisma/client";
import { Request, Response, NextFunction } from "express";
import { prisma } from "../server";
import * as OTPAuth from "otpauth";
import { encode } from "hi-base32";

Register User

The first step in every authentication flow is user registration. We will create a route handler that will add the new user to the database.

controllers/auth.controller.ts


// [...] Register user
const RegisterUser = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const { name, email, password } = req.body;

    await prisma.user.create({
      data: {
        name,
        email,
        password: crypto.createHash("sha256").update(password).digest("hex"),
      },
    });

    res.status(201).json({
      status: "success",
      message: "Registered successfully, please login",
    });
  } catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      if (error.code === "P2002") {
        return res.status(409).json({
          status: "fail",
          message: "Email already exist, please use another email address",
        });
      }
    }
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

Let’s evaluate the above code:

  • First, we extracted the credentials from the request body
  • We called Prisma’s .create() method to add the new record to the database.
  • Then, we returned a success message to the client
  • Lastly, we used a catch block to handle any possible errors.

When a request is made to the /api/auth/register endpoint with the credentials included in the request body, the RegisterUser handler will be evoked to register the user.

nodejs api 2fa register user

Sign-in User

Here, let’s create the route handler that will be called to log the user into the API. To simplify the authentication process, we will ignore other authentication methods and return the user’s data to the client.

The API will assume the user is authenticated when the client includes the user_id in the request body.

controllers/auth.controller.ts


// [...] Register user

// [...] Login user
const LoginUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const { email, password } = req.body;

    const user = await prisma.user.findUnique({ where: { email } });

    if (!user) {
      return res.status(404).json({
        status: "fail",
        message: "No user with that email exists",
      });
    }

    res.status(200).json({
      status: "success",
      user: {
        id: user.id,
        name: user.name,
        email: user.email,
        otp_enabled: user.otp_enabled,
      },
    });
  } catch (error) {
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

This route controller will query the database to check if the user exists and return the found record to the client.

When you make a request to the /api/auth/login endpoint, the LoginUser function will be evoked to sign the user into the API.

nodejs api 2fa signin user

Generate the OTP

It’s now time to create the route handler function that will generate the secret key and the OTP Auth URL. The secret key will link the server and the application that will generate the two-factor authentication tokens.

To generate the base32 encoded secret key, we’ll implement a utility function named generateRandomBase32(). This function leverages the power of Node.js Crypto module and the hi-base32 library to generate a 24-character base32-encoded string. Although a 32-character string can also be generated, a 24-character string suffices for our purposes.

If you require the secret key in different formats such as utf8, hex, buffer, base32, or latin1, the OTPAuth.Secret.fromBase32() method can be utilized to generate the corresponding representations from the base32 string. Here’s an example:


const {base32, hex, utf8,buffer, latin1} =OTPAuth.Secret.fromBase32(generateRandomBase32());

Moving forward, we’ll invoke the totp.toString() method to obtain the OTP Auth URL. This URL encapsulates the encoded secrets as part of the URL structure, alongside other essential configurations required for generating the QR Code. Users can also utilize the base32 string directly to generate the QR Code, providing them with multiple options for setting up their two-factor authentication.

controllers/auth.controller.ts


// [...] Register user

// [...] Login user

// [...] Generate OTP
const generateRandomBase32 = () => {
  const buffer = crypto.randomBytes(15);
  const base32 = encode(buffer).replace(/=/g, "").substring(0, 24);
  return base32;
};

const GenerateOTP = async (req: Request, res: Response) => {
  try {
    const { user_id } = req.body;

    const user = await prisma.user.findUnique({ where: { id: user_id } });

    if (!user) {
      return res.status(404).json({
        status: "fail",
        message: "No user with that email exists",
      });
    }

    const base32_secret = generateRandomBase32();

    let totp = new OTPAuth.TOTP({
      issuer: "codevoweb.com",
      label: "CodevoWeb",
      algorithm: "SHA1",
      digits: 6,
      secret: base32_secret,
    });

    let otpauth_url = totp.toString();

    await prisma.user.update({
      where: { id: user_id },
      data: {
        otp_auth_url: otpauth_url,
        otp_base32: base32_secret,
      },
    });

    res.status(200).json({
      base32: base32_secret,
      otpauth_url,
    });
  } catch (error) {
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

In the provided code snippet, we created the generateRandomBase32() function, responsible for generating a base32-encoded secret key. This key serves as the foundation for the TOTP generation process. Furthermore, we instantiated the OTPAuth.TOTP class with the necessary parameters, assigning it to the totp variable.

Next, we obtained the TOTP Auth URL by invoking the totp.toString() method. This URL contains the encoded secrets and relevant configurations required for generating the QR code or setting up the TOTP authentication in an application. Moreover, we persisted the base32 string and the TOTP Auth URL in the database, ensuring they are readily available for future usage.

Finally, we included the base32 string and the TOTP Auth URL in the JSON response. By returning these values, the client can choose to utilize either the base32 string or the URL to generate the corresponding TOTP tokens, providing flexibility and convenience for the client-side implementation.

When a request is made to the /api/auth/otp/generate endpoint, the GenerateOTP handler will be evoked to generate the two-factor authentication secrets and return them.

Generate the TOTP Auth URL for the two-factor authentication process

Open the Google Authenticator app or Chrome Authenticator extension and enter the base64 string sent by the API.

view the otp token with chrome authenticator extension

You’re not quite finished yet! There’s another essential step to ensure reliable TOTP verification without any pesky invalid errors. It’s crucial to follow this next instruction attentively. Open the Advanced settings, and in the Period field, set the time period to 15 seconds. This value must align perfectly with the server configuration for accurate TOTP validation. Once you’ve made this adjustment, simply click the Ok button to proceed confidently.

Change the TOTP period to 15 to avoid validation errors

Verify the OTP

After entering the secret key, you should see the TOTP displayed in the authenticator app or extension. It’s now time to verify the TOTP code to enable the 2FA feature on the API.

nodejs viewing the otp tokens

You will notice that the otp_enabled column in the “users” table has a default value of false. After the TOTP token has been verified, we will go ahead and set the value of the otp_enabled column to true.

To perform the verification, we need to create a route handler that extracts the user_id and the token generated by the authenticator app from the request body.

controllers/auth.controller.ts


// [...] Register user

// [...] Login user

// [...] Generate OTP

// [...] Verify OTP
const VerifyOTP = async (req: Request, res: Response) => {
  try {
    const { user_id, token } = req.body;

    const user = await prisma.user.findUnique({ where: { id: user_id } });
    const message = "Token is invalid or user doesn't exist";
    if (!user) {
      return res.status(401).json({
        status: "fail",
        message,
      });
    }

    let totp = new OTPAuth.TOTP({
      issuer: "codevoweb.com",
      label: "CodevoWeb",
      algorithm: "SHA1",
      digits: 6,
      secret: user.otp_base32!,
    });

    let delta = totp.validate({ token });

    if (delta === null) {
      return res.status(401).json({
        status: "fail",
        message,
      });
    }

    const updatedUser = await prisma.user.update({
      where: { id: user_id },
      data: {
        otp_enabled: true,
        otp_verified: true,
      },
    });

    res.status(200).json({
      otp_verified: true,
      user: {
        id: updatedUser.id,
        name: updatedUser.name,
        email: updatedUser.email,
        otp_enabled: updatedUser.otp_enabled,
      },
    });
  } catch (error) {
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

Let’s evaluate the above code snippet to gain a better understanding of its functionality. Here’s a detailed analysis:

  1. We begin by extracting the user ID and the TOTP token from the request body. This allows us to retrieve the necessary information for validation.
  2. Using Prisma, we search for a user with the provided ID. If no matching user is found, we return a 401 Unauthorized response to the client, indicating that the token is invalid or the user does not exist.
  3. Next, we instantiate a new instance of the OTPAuth.TOTP class. This class provides functionality for generating and validating TOTP tokens. We initialize it with the base32-encoded key retrieved from the database as the secret key for verification.
  4. We then call the totp.validate() method to validate the provided token. This method compares the token against the expected value generated by the TOTP algorithm. If the token is not found within the valid time window, the method returns null, indicating that the token is invalid.
  5. Upon successful verification of the token, we update the user’s credentials in the database. By setting both the otp_verified and otp_enabled fields to true, we indicate that the user’s TOTP authentication is verified and enabled.

To verify the TOTP token, make a POST request to the /api/auth/otp/verify endpoint with the user_id and token included in the request body.

nodejs api 2fa verify the otp token

Validate the OTP

To validate the TOTP token entered by the user from their authenticator app during subsequent logins, we will create a dedicated route handler named ValidateOTP. This function is designed to verify the token against the secret key stored in the database.

The ValidateOTP function shares a similar logic with the VerifyOTP function mentioned earlier, but with a distinct purpose. Unlike VerifyOTP, the ValidateOTP function does not update the user’s credentials in the database. Its primary objective is to validate the provided token and return the result indicating whether it is valid or not.

controllers/auth.controller.ts


// [...] Register user

// [...] Login user

// [...] Generate OTP

// [...] Verify OTP

// [...] Validate OTP
const ValidateOTP = async (req: Request, res: Response) => {
  try {
    const { user_id, token } = req.body;
    const user = await prisma.user.findUnique({ where: { id: user_id } });

    const message = "Token is invalid or user doesn't exist";
    if (!user) {
      return res.status(401).json({
        status: "fail",
        message,
      });
    }
    let totp = new OTPAuth.TOTP({
      issuer: "codevoweb.com",
      label: "CodevoWeb",
      algorithm: "SHA1",
      digits: 6,
      secret: user.otp_base32!,
    });

    let delta = totp.validate({ token, window: 1 });

    if (delta === null) {
      return res.status(401).json({
        status: "fail",
        message,
      });
    }

    res.status(200).json({
      otp_valid: true,
    });
  } catch (error) {
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

As you can see in the code snippet above, we instantiate a new object of the OTPAuth.TOTP class, passing in the base32-encoded string retrieved from the database. This object represents the TOTP configuration associated with the user.

Next, we use the totp.validate() method to compare the token provided by the user with the expected value generated by the TOTP algorithm. If the validation fails, indicating an incorrect or expired token, we send a 401 UnAuthorized response back to the client, signaling that the token is invalid.

On the other hand, if the validation succeeds, we include otp_valid: true in the JSON response, indicating that the token is valid and authentication is successful.

If you’d like to learn more about the window parameter and its role, you can check out the ReadMe on the Speakeasy library’s GitHub page at https://github.com/speakeasyjs/speakeasy. Even though we’re not using that library in our project, the ReadMe provides a detailed guide on how TOTP generation and validation work. It’s worth reading and understanding to get a clearer picture of the concepts involved, even if you’re not directly using the library itself.

Now make a POST request to the /api/auth/otp/validate endpoint with the user_id and token provided in the request body to validate the token.

nodejs api 2fa validate the otp code

Disable the OTP Feature

The final step in the two-factor (2FA) authentication is to create a controller that will be evoked to disable the 2FA feature. When this function is called, it will update the otp_enabled column to false in the database.

controllers/auth.controller.ts


// [...] Register user

// [...] Login user

// [...] Generate OTP

// [...] Verify OTP

// [...] Validate OTP

// [...] Disable OTP
const DisableOTP = async (req: Request, res: Response) => {
  try {
    const { user_id } = req.body;

    const user = await prisma.user.findUnique({ where: { id: user_id } });
    if (!user) {
      return res.status(401).json({
        status: "fail",
        message: "User doesn't exist",
      });
    }

    const updatedUser = await prisma.user.update({
      where: { id: user_id },
      data: {
        otp_enabled: false,
      },
    });

    res.status(200).json({
      otp_disabled: true,
      user: {
        id: updatedUser.id,
        name: updatedUser.name,
        email: updatedUser.email,
        otp_enabled: updatedUser.otp_enabled,
      },
    });
  } catch (error) {
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

To disable the 2FA feature, make a POST request to the /api/auth/otp/disable endpoint with the TOTP token and user_id provided in the request body.

nodejs api disable 2fa

Complete Code of the Route Handlers

controllers/auth.controller.ts


import crypto from "crypto";
import { Prisma } from "@prisma/client";
import { Request, Response, NextFunction } from "express";
import { prisma } from "../server";
import * as OTPAuth from "otpauth";
import { encode } from "hi-base32";

const RegisterUser = async (
  req: Request,
  res: Response,
  next: NextFunction
) => {
  try {
    const { name, email, password } = req.body;

    await prisma.user.create({
      data: {
        name,
        email,
        password: crypto.createHash("sha256").update(password).digest("hex"),
      },
    });

    res.status(201).json({
      status: "success",
      message: "Registered successfully, please login",
    });
  } catch (error) {
    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      if (error.code === "P2002") {
        return res.status(409).json({
          status: "fail",
          message: "Email already exist, please use another email address",
        });
      }
    }
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

const LoginUser = async (req: Request, res: Response, next: NextFunction) => {
  try {
    const { email, password } = req.body;

    const user = await prisma.user.findUnique({ where: { email } });

    if (!user) {
      return res.status(404).json({
        status: "fail",
        message: "No user with that email exists",
      });
    }

    res.status(200).json({
      status: "success",
      user: {
        id: user.id,
        name: user.name,
        email: user.email,
        otp_enabled: user.otp_enabled,
      },
    });
  } catch (error) {
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

const generateRandomBase32 = () => {
  const buffer = crypto.randomBytes(15);
  const base32 = encode(buffer).replace(/=/g, "").substring(0, 24);
  return base32;
};

const GenerateOTP = async (req: Request, res: Response) => {
  try {
    const { user_id } = req.body;

    const user = await prisma.user.findUnique({ where: { id: user_id } });

    if (!user) {
      return res.status(404).json({
        status: "fail",
        message: "No user with that email exists",
      });
    }

    const base32_secret = generateRandomBase32();

    let totp = new OTPAuth.TOTP({
      issuer: "codevoweb.com",
      label: "CodevoWeb",
      algorithm: "SHA1",
      digits: 6,
      secret: base32_secret,
    });

    let otpauth_url = totp.toString();

    await prisma.user.update({
      where: { id: user_id },
      data: {
        otp_auth_url: otpauth_url,
        otp_base32: base32_secret,
      },
    });

    res.status(200).json({
      base32: base32_secret,
      otpauth_url,
    });
  } catch (error) {
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

const VerifyOTP = async (req: Request, res: Response) => {
  try {
    const { user_id, token } = req.body;

    const user = await prisma.user.findUnique({ where: { id: user_id } });
    const message = "Token is invalid or user doesn't exist";
    if (!user) {
      return res.status(401).json({
        status: "fail",
        message,
      });
    }

    let totp = new OTPAuth.TOTP({
      issuer: "codevoweb.com",
      label: "CodevoWeb",
      algorithm: "SHA1",
      digits: 6,
      secret: user.otp_base32!,
    });

    let delta = totp.validate({ token });

    if (delta === null) {
      return res.status(401).json({
        status: "fail",
        message,
      });
    }

    const updatedUser = await prisma.user.update({
      where: { id: user_id },
      data: {
        otp_enabled: true,
        otp_verified: true,
      },
    });

    res.status(200).json({
      otp_verified: true,
      user: {
        id: updatedUser.id,
        name: updatedUser.name,
        email: updatedUser.email,
        otp_enabled: updatedUser.otp_enabled,
      },
    });
  } catch (error) {
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

const ValidateOTP = async (req: Request, res: Response) => {
  try {
    const { user_id, token } = req.body;
    const user = await prisma.user.findUnique({ where: { id: user_id } });

    const message = "Token is invalid or user doesn't exist";
    if (!user) {
      return res.status(401).json({
        status: "fail",
        message,
      });
    }
    let totp = new OTPAuth.TOTP({
      issuer: "codevoweb.com",
      label: "CodevoWeb",
      algorithm: "SHA1",
      digits: 6,
      secret: user.otp_base32!,
    });

    let delta = totp.validate({ token, window: 1 });

    if (delta === null) {
      return res.status(401).json({
        status: "fail",
        message,
      });
    }

    res.status(200).json({
      otp_valid: true,
    });
  } catch (error) {
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

const DisableOTP = async (req: Request, res: Response) => {
  try {
    const { user_id } = req.body;

    const user = await prisma.user.findUnique({ where: { id: user_id } });
    if (!user) {
      return res.status(401).json({
        status: "fail",
        message: "User doesn't exist",
      });
    }

    const updatedUser = await prisma.user.update({
      where: { id: user_id },
      data: {
        otp_enabled: false,
      },
    });

    res.status(200).json({
      otp_disabled: true,
      user: {
        id: updatedUser.id,
        name: updatedUser.name,
        email: updatedUser.email,
        otp_enabled: updatedUser.otp_enabled,
      },
    });
  } catch (error) {
    res.status(500).json({
      status: "error",
      message: error.message,
    });
  }
};

export default {
  RegisterUser,
  LoginUser,
  GenerateOTP,
  VerifyOTP,
  ValidateOTP,
  DisableOTP,
};

Step 7 – Create the Express API Routes

Now that we have all the route handlers defined, let’s create routes to evoke them. To do that, create a routes/auth.route.ts file and add the following code.

routes/auth.route.ts


import express from "express";
import authController from "../controllers/auth.controller";

const router = express.Router();

router.post("/register", authController.RegisterUser);
router.post("/login", authController.LoginUser);
router.post("/otp/generate", authController.GenerateOTP);
router.post("/otp/verify", authController.VerifyOTP);
router.post("/otp/validate", authController.ValidateOTP);
router.post("/otp/disable", authController.DisableOTP);

export default router;

Step 8 – Add the Routes to the Middleware Stack

Finally, add the Express router we defined above to the middleware pipeline.

server.ts


import { PrismaClient } from "@prisma/client";
import express, { Request, Response } from "express";
import cors from "cors";
import authRouter from "./routes/auth.route";
import morgan from "morgan";

export const prisma = new PrismaClient();
const app = express();

async function main() {
  // Middleware
  app.use(morgan("dev"));
  app.use(
    cors({
      origin: ["http://localhost:3000"],
      credentials: true,
    })
  );
  app.use(express.json());

  //   Health Checker
  app.get("/api/healthchecker", (res: Response) => {
    res.status(200).json({
      status: "success",
      message: "Welcome to Two-Factor Authentication with Node.js",
    });
  });

  app.use("/api/auth", authRouter);

  app.all("*", (req: Request, res: Response) => {
    return res.status(404).json({
      status: "fail",
      message: `Route: ${req.originalUrl} not found`,
    });
  });

  const PORT = 8000;
  app.listen(PORT, () => {
    console.info(`Server started on port: ${PORT}`);
  });
}

main()
  .then(async () => {
    await prisma.$disconnect();
  })
  .catch(async (e) => {
    console.error(e);
    await prisma.$disconnect();
    process.exit(1);
  });

Start the Node.js server by running:


yarn ts-node-dev --respawn --transpile-only --exit-child server.ts
# or 
npx ts-node-dev --respawn --transpile-only --exit-child server.ts

Conclusion

In this article, you learned how to implement two-factor (2FA) authentication in Node.js using the hi-base32 and otpauth libraries. You also learned how to use the authenticator extension in chrome to generate the TOTP tokens.

You can find the frontend and backend source code on this GitHub repository: