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.

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

Let’s talk about the main focus of this article. We will be using the Speakeasy 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.

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

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 speakeasy && yarn add -D morgan ts-node-dev @types/cors @types/express @types/morgan @types/speakeasy
# or 
npm install express cors speakeasy && npm install -D morgan ts-node-dev @types/cors @types/express @types/morgan @types/speakeasy

  • express – A Node.js web framework
  • cors – Enable CORS in Express
  • speakeasy – A one-time passcode generator
  • 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",
    "@types/speakeasy": "^2.0.7",
    "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",
    "speakeasy": "^2.0.0"
  }
}


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 to handle the two-factor authentication.

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 handler that will generate the secret key. The secret key will link the server and the application that will generate the two-factor authentication tokens.

To generate the secret key, we will leverage Speakeasy’s .generateSecret() method. This method returns an object that holds the secret key in ASCIIhex, base32, and otpauth_url format. The otpauth_url has secrets encoded in it as a URL that will be used to generate the QR Code. Also, the user can use the base32 string to generate the QR Code.

controllers/auth.controller.ts


// [...] Register user

// [...] Login user

// [...] Generate OTP
const GenerateOTP = async (req: Request, res: Response) => {
  try {
    const { user_id } = req.body;
    const { ascii, hex, base32, otpauth_url } = speakeasy.generateSecret({
      issuer: "codevoweb.com",
      name: "admin@admin.com",
      length: 15,
    });

    await prisma.user.update({
      where: { id: user_id },
      data: {
        otp_ascii: ascii,
        otp_auth_url: otpauth_url,
        otp_base32: base32,
        otp_hex: hex,
      },
    });

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

After the 2FA secrets have been generated, we called Prisma’s .update() method to store them in the database.

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.

nodejs api 2fa generate the otp auth url

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

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

    const verified = speakeasy.totp.verify({
      secret: user.otp_base32!,
      encoding: "base32",
      token,
    });

    if (!verified) {
      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,
    });
  }
};

Next, the handler will check the database to see if a user with that ID exists before Speakeasy’s .totp.verify() method will be evoked to verify the token. After a successful verification, we will update the user’s credentials in the database.

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

This route handler will be evoked to verify the TOTP token that the user enters from their authenticator app. This function will verify the token against the secret key stored in the database.

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

    const validToken = speakeasy.totp.verify({
      secret: user?.otp_base32!,
      encoding: "base32",
      token,
      window: 1,
    });

    if (!validToken) {
      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,
    });
  }
};

To validate the TOTP token, call Speakeasy’s .totp.verify() function but this time provide the window param in addition to the other arguments. The window specifies the period of time that the TOTP token is valid. For more details about the window parameter, read https://github.com/speakeasyjs/speakeasy.

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 speakeasy from "speakeasy";

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 GenerateOTP = async (req: Request, res: Response) => {
  try {
    const { user_id } = req.body;
    const { ascii, hex, base32, otpauth_url } = speakeasy.generateSecret({
      issuer: "codevoweb.com",
      name: "admin@admin.com",
      length: 15,
    });

    await prisma.user.update({
      where: { id: user_id },
      data: {
        otp_ascii: ascii,
        otp_auth_url: otpauth_url,
        otp_base32: base32,
        otp_hex: hex,
      },
    });

    res.status(200).json({
      base32,
      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,
      });
    }

    const verified = speakeasy.totp.verify({
      secret: user.otp_base32!,
      encoding: "base32",
      token,
    });

    if (!verified) {
      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,
      });
    }

    const validToken = speakeasy.totp.verify({
      secret: user?.otp_base32!,
      encoding: "base32",
      token,
      window: 1,
    });

    if (!validToken) {
      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 authentication in Node.js using the Speakeasy library. 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: