This article will teach you how to secure a React.js app by implementing two-factor authentication (2FA) feature using TOTP tokens generated by Google Authenticator, Authy, or Chrome’s Authenticator extension.

You will learn how to:

  • Validate HTML forms with React-Hook-Form and Zod
  • Make API calls with Axios
  • Register users with React.js
  • Sign users into their accounts
  • Generate and verify QR codes
  • Implement 2FA in React.js

Related articles:

More practice:

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

Prerequisite

Before proceeding with this tutorial, you should have the following prerequisites to get the most out of this article:

  • Have basic knowledge of JavaScript and TypeScript
  • Have a basic understanding of API designs and how to interact with APIs.
  • Familiarity with React.js will be beneficial.
  • Have basic knowledge of CSS and TailwindCSS

Run the 2FA Backend APIs Locally

Based on the programming language you are comfortable with, you can run either the Golang or Node.js 2FA API by following the instructions below.

2FA Backend API Built with Golang

Read the post How to Implement (2FA) Two-factor Authentication in Golang to get a full guide on how to build the 2FA backend API with Golang.

  1. Download and install the latest version of Golang from https://go.dev/doc/install
  2. Download or clone the two-factor authentication (2FA) Golang source code from https://github.com/wpcodevo/two_factor_golang
  3. Install all the required packages by running go mod tidy from the terminal in the root directory.
  4. Start the Golang Gin HTTP server by running go run main.go
  5. Open any API testing tool and interact with the 2FA verification system

2FA Backend API with Node.js

Read the post How to Implement Two-factor Authentication (2FA) in Node.js to get a full guide on how to build the two-factor authentication (2FA) API with TypeScript and Node.js.

  1. Download and install Node.js from https://nodejs.org/.
  2. Download or clone the two-factor authentication (2FA) Node.js source code from https://github.com/wpcodevo/2fa-nodejs
  3. Install all the necessary dependencies by running yarn or yarn install from the terminal in the root folder.
  4. This project uses Prisma ORM to add data to an SQLite database. Run yarn db:migrate and yarn db:push to push the Prisma schema to the SQLite database.
  5. Start the Node.js HTTP server by running yarn start
  6. Test the API endpoints with your API testing software like Postman.

Run the 2FA React App Locally

  • Visit https://github.com/wpcodevo/two_factor_reactjs to download or clone the React.js two-factor authentication (2FA) source code.
  • Run yarn or yarn install to install all the required dependencies.
  • Start the Vite dev server by running yarn dev .
  • Navigate to http://localhost:3000 in a new tab to start testing the two-factor authentication system against the Node.js or Golang API.

React.js Two-Factor Authentication Overview

The React two-factor authentication app will have the following routes:

  • /login – a public route for signing users into the React app
  • /register – a public route for registering new users
  • /validateOtp – a public route for verifying the OTP tokens generated by the authenticator app
  • /profile – a private route where the 2FA feature can be enabled
  • / – a public route for displaying a simple message

Setup the 2FA Feature

After creating an account and logging into the app, React will redirect you to the profile page where you can enable the 2FA feature.

To enable the two-factor authentication feature in React, click on the Setup 2FA button on the profile page. When the Setup 2FA button is clicked, React will make a POST request to the /api/auth/otp/generate endpoint. The API will generate the OTP secret key, store the secret key in the database, and return the OTP URL to React.

reactjs setup 2fa with totp

Scan the Generated QR Code

After React receives the OTP URL, it will render a popup, generate a QR Code from the OTP URL and display the QR Code in addition to the base32 string sent by the API.

reactjs setup 2fa scan the qrcode

To display the One Time Password, open any authenticator app ( Authenticator, Authy, or Chrome’s Authenticator extension ) and scan the QR Code.

Alternatively, you can use the manual option to display the OTP tokens by entering the base32 string.

display the totp token with chrome authenticator

Verify the OTP Code

Once the OTP tokens are been displayed by the authenticator app, copy the current OTP, paste it into the text field on the popup and click the Verify & Activate button.

When the Verify & Activate button is clicked, an Axios POST request will be fired to the /api/auth/otp/verify API endpoint. The API will verify the OTP against the secret key stored in the database and return a response to React.

reactjs 2fa display the totp token with an authenticator app

Validate the OTP Code

After the 2FA feature has been enabled, reload the page to log out from the app. When you submit your credentials to the API through the login page, React will redirect you to the OTP verification page.

Open the authenticator app, copy the OTP code, enter the token into the input field, and click the Authenticate button. React will make a POST request to /api/auth/otp/validate endpoint for the OTP to be verified by the API.

reactjs 2fa verify the totp token

Disable the 2FA Feature

Once the OTP code has been verified by the API and you’ve been redirected to the profile page, you can disable the 2FA feature by clicking the Disable 2FA button.

When you try logging into the app after the 2FA feature is disabled, you will be taken straight to the profile page instead of going through the OTP verification step.

reactjs 2fa disable the feature

Step 1 – Setup React.js with Tailwind CSS

To begin, let’s use the Vite scaffolding tool to bootstrap the React.js project. Vite comes with a lot of benefits over Create React App which makes Vite significantly faster than CRA.

Run the following command to set up the React project with Vite. You can name the project 2fa_reactjs .


yarn create vite 2fa_reactjs --template react-ts
# or
npm create vite@latest 2fa_reactjs -- --template react-ts

This will generate the React.js project and configure it to use TypeScript. Open the project with your preferred IDE or text editor and run yarn install or npm install to install all the necessary dependencies.

After that, open the package.json file and replace the dev script with "dev": "vite --host localhost --port 3000" . This will instruct Vite to use port 3000 for the development server.

With that out of the way, let’s set up tailwind CSS. To do that, install the following dependencies:


yarn add -D tailwindcss postcss autoprefixer
# or
npm install -D tailwindcss postcss autoprefixer

Next, run the tailwind CSS init command to generate the tailwind.config.cjs and postcss.config.cjs files.


yarn tailwindcss init -p
# or 
npx tailwindcss init -p

Open the tailwind.config.cjs file and replace its content with the following tailwind CSS configurations.

tailwind.config.cjs


/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
    "./index.html",
    "./src/**/*.{vue,js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {
      colors: {
        'ct-dark-600': '#222',
        'ct-dark-200': '#e5e7eb',
        'ct-dark-100': '#f5f6f7',
        'ct-blue-600': '#2363eb',
        'ct-yellow-600': '#f9d13e',
      },
      fontFamily: {
        Poppins: ['Poppins, sans-serif'],
      },
      container: {
        center: true,
        padding: '1rem',
        screens: {
          lg: '1125px',
          xl: '1125px',
          '2xl': '1125px',
        },
      },
    },
  },
  plugins: [],
};


Next, replace the content of the src/index.css file with the following CSS code and tailwind directives.

src/index.css


@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap');

@tailwind base;
@tailwind components;
@tailwind utilities;

html{
    font-family: 'Poppins', sans-serif;
}

After following all the above configurations, start the Vite development server by running yarn dev or npm run dev .

Step 2 – Setup Axios and Zustand

In this step, you will create an Axios instance, add a default configuration to the instance and configure the React app to use the Zustand state manager.

To do this, install the following packages:


yarn add axios zustand
# or 
npm install axios zustand

  • axios – An HTTP client for the browser and node.js
  • zustand – A lightweight React.js state management library

Setup Axios

Axios is an HTTP client that can be used to make HTTP or GraphQL requests in the browser or Node.js. Because TypeScript requires us to type the API responses, create a src/api/types.ts file and add the following TypeScript interfaces.

src/api/types.ts


export interface IUser {
  id: string;
  name: string;
  email: string;
  otp_enabled: string;
}

export interface GenericResponse {
  status: string;
  message: string;
}

export interface ILoginResponse {
  status: string;
  user: IUser;
}


By default, Axios comes with an instance that can be used for API requests but to use a custom Axios instance, we need to use Axios’ .create() method and provide it with our own configuration options.

Using a custom Axios instance will allow us to:

  • Use the same configurations for all API invocations done through the instance.
  • Append default configurations to the instance.

Now create a src/api/authApi.ts file and add the following code:

src/api/authApi.ts


import axios from "axios";

const BASE_URL = "http://localhost:8000/api/";

export const authApi = axios.create({
  baseURL: BASE_URL,
  withCredentials: true,
});

authApi.defaults.headers.common["Content-Type"] = "application/json";

  • baseURL – Relative URL for the Axios instance
  • withCredentials – Tells the browser to include the cookies in the requests.

We created an Axios instance with the name authApi and appended a default HTTP header to the instance.

Setup Zustand State Manager

Zustand is a lightweight, fast, and scalable React.js state management library. It uses simplified flux principles and a comfy API based on hooks to make state management in React a breeze.

Lately, Zustand has been gaining a lot of traction because it integrates well with JavaScript frameworks like Svelte, Vue, Angular, and even Vanilla JS.

With the above explanation, create a src/store/index.ts file and add the following code snippets.

src/store/index.ts


import create from 'zustand';
import { IUser } from '../api/types';

type Store = {
  authUser: IUser | null;
  requestLoading: boolean;
  setAuthUser: (user: IUser | null) => void;
  setRequestLoading: (isLoading: boolean) => void;
};

const useStore = create<Store>((set) => ({
  authUser: null,
  requestLoading: false,
  setAuthUser: (user) => set((state) => ({ ...state, authUser: user })),
  setRequestLoading: (isLoading) =>
    set((state) => ({ ...state, requestLoading: isLoading })),
}));

export default useStore;

  • Store – TypeScript types for the Zustand store
  • useStore – A hook that has a lot of properties and methods for managing the Zustand store.

Step 3 – Create Reusable React Components

In this step, you will create reusable React components like a spinner, loading button, input field, and 2fa modal. To do this, install the following dependencies:


yarn add tailwind-merge react-router-dom react-hook-form zod @hookform/resolvers react-toastify qrcode && yarn add -D @types/qrcode
# or 
npm install tailwind-merge react-router-dom react-hook-form zod @hookform/resolvers react-toastify qrcode && npm install -D @types/qrcode

Spinner Component

The first component will be a spinner which will be displayed to indicate that a request is been processed by the API. Create a src/components/Spinner.tsx file and add the following code:

src/components/Spinner.tsx


import React from 'react';
import { twMerge } from 'tailwind-merge';
type SpinnerProps = {
  width?: number;
  height?: number;
  color?: string;
  bgColor?: string;
};
const Spinner: React.FC<SpinnerProps> = ({
  width = 5,
  height = 5,
  color,
  bgColor,
}) => {
  return (
    <svg
      role='status'
      className={twMerge(
        'w-5 h-5 mr-2 text-gray-200 animate-spin dark:text-gray-600 fill-blue-600',
        `w-${width} h-${height} ${color} ${bgColor}`
      )}
      viewBox='0 0 100 101'
      fill='none'
      xmlns='http://www.w3.org/2000/svg'
    >
      <path
        d='M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z'
        fill='currentColor'
      />
      <path
        d='M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z'
        fill='currentFill'
      />
    </svg>
  );
};

export default Spinner;

Loading Button Component

The loading button will have a spinner and text. The spinner will be invisible by default but when a request is in flight, React will make the spinner visible while waiting for a response from the API.

src/components/LoadingButton.tsx


import React from "react";
import { twMerge } from "tailwind-merge";
import Spinner from "./Spinner";

type LoadingButtonProps = {
  loading: boolean;
  btnColor?: string;
  textColor?: string;
  children: React.ReactNode;
};

export const LoadingButton: React.FC<LoadingButtonProps> = ({
  textColor = "text-white",
  btnColor = "bg-ct-yellow-600",
  children,
  loading = false,
}) => {
  return (
    <button
      type="submit"
      className={twMerge(
        `w-full py-3 font-semibold rounded-lg outline-none border-none flex justify-center`,
        `${btnColor} ${loading && "bg-[#ccc]"}`
      )}
    >
      {loading ? (
        <div className="flex items-center gap-3">
          <Spinner />
          <span className="text-slate-500 inline-block">Loading...</span>
        </div>
      ) : (
        <span className={`${textColor}`}>{children}</span>
      )}
    </button>
  );
};

Header Component

The Header component will display some menu links to make navigation through the app a breeze. Even though the handleLogout() function is defined, I omitted its functionality to reduce the complexity of the project.

src/components/Header.tsx


import { Link } from "react-router-dom";
import useStore from "../store";
import Spinner from "./Spinner";

const Header = () => {
  const store = useStore();
  const user = store.authUser;

  const handleLogout = () => {};

  return (
    <>
      <header className="bg-white h-20">
        <nav className="h-full flex justify-between container items-center">
          <div>
            <Link to="/" className="text-ct-dark-600 text-2xl font-semibold">
              CodevoWeb
            </Link>
          </div>
          <ul className="flex items-center gap-4">
            <li>
              <Link to="/" className="text-ct-dark-600">
                Home
              </Link>
            </li>
            {!user && (
              <>
                <li>
                  <Link to="/register" className="text-ct-dark-600">
                    SignUp
                  </Link>
                </li>
                <li>
                  <Link to="/login" className="text-ct-dark-600">
                    Login
                  </Link>
                </li>
              </>
            )}
            {user && (
              <>
                <li>
                  <Link to="/profile" className="text-ct-dark-600">
                    Profile
                  </Link>
                </li>
                <li className="cursor-pointer" onClick={handleLogout}>
                  Logout
                </li>
              </>
            )}
          </ul>
        </nav>
      </header>
      <div className="pt-4 pl-2 bg-ct-blue-600 fixed">
        {store.requestLoading && <Spinner color="text-ct-yellow-600" />}
      </div>
    </>
  );
};

export default Header;

Form Input Component

Here, we will use the power of React-Hook-Form to create a reusable form input component that we can use on all the pages that need a form component. To do that, create a src/components/FormInput.tsx file and add the following code:

src/components/FormInput.tsx


import React from 'react';
import { useFormContext } from 'react-hook-form';

type FormInputProps = {
  label: string;
  name: string;
  type?: string;
};

const FormInput: React.FC<FormInputProps> = ({
  label,
  name,
  type = 'text',
}) => {
  const {
    register,
    formState: { errors },
  } = useFormContext();
  return (
    <div className=''>
      <label htmlFor={name} className='block text-ct-blue-600 mb-3'>
        {label}
      </label>
      <input
        type={type}
        placeholder=' '
        className='block w-full rounded-2xl appearance-none focus:outline-none py-2 px-4'
        {...register(name)}
      />
      {errors[name] && (
        <span className='text-red-500 text-xs pt-1 block'>
          {errors[name]?.message as string}
        </span>
      )}
    </div>
  );
};

export default FormInput;

We used the useFormContext() hook to ensure that the FormInput component has access to the form context when used in the parent component.

Two-Factor Auth Component

It’s now time to create the popup component that will display the QR Code from the OTP URL sent by the API. This component will serve two purposes:

  1. Generate and display the QR Code
  2. Submit the OTP token to the API

src/components/TwoFactorAuth.tsx


import { FC, useEffect, useState } from "react";
import QRCode from "qrcode";
import { useForm, SubmitHandler } from "react-hook-form";
import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { authApi } from "../api/authApi";
import { toast } from "react-toastify";
import useStore from "../store";
import { IUser } from "../api/types";

const styles = {
  heading3: `text-xl font-semibold text-gray-900 p-4 border-b`,
  heading4: `text-base text-ct-blue-600 font-medium border-b mb-2`,
  modalOverlay: `overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 w-full md:inset-0 h-modal md:h-full`,
  orderedList: `space-y-1 text-sm list-decimal`,
  buttonGroup: `flex items-center py-6 space-x-2 rounded-b border-t border-gray-200 dark:border-gray-600`,
  buttonBlue: `text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800`,
  buttonGrey: `text-gray-500 bg-white hover:bg-gray-100 focus:ring-4 focus:outline-none focus:ring-blue-300 rounded-lg border border-gray-200 text-sm font-medium px-5 py-2.5 hover:text-gray-900 focus:z-10 dark:bg-gray-700 dark:text-gray-300 dark:border-gray-500 dark:hover:text-white dark:hover:bg-gray-600 dark:focus:ring-gray-600`,
  inputField: `bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-2/5 p-2.5`,
};

type TwoFactorAuthProps = {
  otpauth_url: string;
  base32: string;
  user_id: string;
  closeModal: () => void;
};

const twoFactorAuthSchema = object({
  token: string().min(1, "Authentication code is required"),
});

type TwoFactorAuthInput = TypeOf<typeof twoFactorAuthSchema>;

const TwoFactorAuth: FC<TwoFactorAuthProps> = ({
  otpauth_url,
  base32,
  user_id,
  closeModal,
}) => {
  const [qrcodeUrl, setqrCodeUrl] = useState("");
  const store = useStore();

  const {
    handleSubmit,
    register,
    formState: { errors },
    setFocus,
  } = useForm<TwoFactorAuthInput>({
    resolver: zodResolver(twoFactorAuthSchema),
  });

  const verifyOtp = async (token: string) => {
    try {
      store.setRequestLoading(true);
      const {
        data: { user },
      } = await authApi.post<{ otp_verified: string; user: IUser }>(
        "/auth/otp/verify",
        {
          token,
          user_id,
        }
      );
      store.setRequestLoading(false);
      store.setAuthUser(user);
      closeModal();
      toast.success("Two-Factor Auth Enabled Successfully", {
        position: "top-right",
      });
    } catch (error: any) {
      store.setRequestLoading(false);
      const resMessage =
        (error.response &&
          error.response.data &&
          error.response.data.message) ||
        error.response.data.detail ||
        error.message ||
        error.toString();
      toast.error(resMessage, {
        position: "top-right",
      });
    }
  };

  const onSubmitHandler: SubmitHandler<TwoFactorAuthInput> = (values) => {
    verifyOtp(values.token);
  };

  useEffect(() => {
    QRCode.toDataURL(otpauth_url).then(setqrCodeUrl);
  }, []);

  useEffect(() => {
    setFocus("token");
  }, [setFocus]);
  return (
    <div
      aria-hidden={true}
      className="overflow-y-auto overflow-x-hidden fixed top-0 right-0 left-0 z-50 w-full md:inset-0 h-modal md:h-full bg-[#222] bg-opacity-50"
      // onClick={closeModal}
    >
      <div className="relative p-4 w-full max-w-xl h-full md:h-auto left-1/2 -translate-x-1/2">
        <div className="relative bg-white rounded-lg shadow">
          <h3 className={styles.heading3}>Two-Factor Authentication (2FA)</h3>
          {/* Modal body */}
          <div className="p-6 space-y-4">
            <h4 className={styles.heading4}>
              Configuring Google Authenticator or Authy
            </h4>
            <div className={styles.orderedList}>
              <li>
                Install Google Authenticator (IOS - Android) or Authy (IOS -
                Android).
              </li>
              <li>In the authenticator app, select "+" icon.</li>
              <li>
                Select "Scan a barcode (or QR code)" and use the phone's camera
                to scan this barcode.
              </li>
            </div>
            <div>
              <h4 className={styles.heading4}>Scan QR Code</h4>
              <div className="flex justify-center">
                <img
                  className="block w-64 h-64 object-contain"
                  src={qrcodeUrl}
                  alt="qrcode url"
                />
              </div>
            </div>
            <div>
              <h4 className={styles.heading4}>Or Enter Code Into Your App</h4>
              <p className="text-sm">SecretKey: {base32} (Base32 encoded)</p>
            </div>
            <div>
              <h4 className={styles.heading4}>Verify Code</h4>
              <p className="text-sm">
                For changing the setting, please verify the authentication code:
              </p>
            </div>
            <form onSubmit={handleSubmit(onSubmitHandler)}>
              <input
                {...register("token")}
                className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-2/4 p-2.5"
                placeholder="Authentication Code"
              />
              <p className="mt-2 text-xs text-red-600">
                {errors.token ? errors.token.message : null}
              </p>

              <div className={styles.buttonGroup}>
                <button
                  type="button"
                  onClick={closeModal}
                  className={styles.buttonGrey}
                >
                  Close
                </button>
                <button type="submit" className={styles.buttonBlue}>
                  Verify & Activate
                </button>
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
  );
};

export default TwoFactorAuth;

Step 4 – Create the Authentication Components

Now that we’ve created the reusable React components, set up the Zustand state management library, and configured Axios, it’s now time to define the React.js route components.

  • RegisterPage – This component is responsible for registering new users.
  • LoginPage – This component is responsible for logging users into the React app
  • Validate2faPage – This component is responsible for validating the OTP token when the 2FA feature is enabled
  • HomePage – This component simply displays a welcome message
  • ProfilePage – This component is responsible for showing the user’s credentials. It’s also the place where the 2FA feature can be enabled/disabled.

User Registration Component

This React component will submit the account registration credentials to the API by firing an Axios POST request to the /api/auth/register endpoint. The component will have a form that has name, email, password, and password confirmation fields. The form validation will be handled by the React-Hook-Form library and the validation schema will be defined with Zod.

src/pages/register.page.tsx


import { object, string, TypeOf } from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
import { useEffect } from "react";
import FormInput from "../components/FormInput";
import { LoadingButton } from "../components/LoadingButton";
import { toast } from "react-toastify";
import { Link, useNavigate } from "react-router-dom";
import { authApi } from "../api/authApi";
import useStore from "../store";
import { GenericResponse } from "../api/types";

const registerSchema = object({
  name: string().min(1, "Full name is required").max(100),
  email: string()
    .min(1, "Email address is required")
    .email("Email Address is invalid"),
  password: string()
    .min(1, "Password is required")
    .min(8, "Password must be more than 8 characters")
    .max(32, "Password must be less than 32 characters"),
  passwordConfirm: string().min(1, "Please confirm your password"),
}).refine((data) => data.password === data.passwordConfirm, {
  path: ["passwordConfirm"],
  message: "Passwords do not match",
});

export type RegisterInput = TypeOf<typeof registerSchema>;

const RegisterPage = () => {
  const navigate = useNavigate();
  const store = useStore();

  const methods = useForm<RegisterInput>({
    resolver: zodResolver(registerSchema),
  });

  const {
    reset,
    handleSubmit,
    formState: { isSubmitSuccessful },
  } = methods;

  useEffect(() => {
    if (isSubmitSuccessful) {
      reset();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isSubmitSuccessful]);

  const registerUser = async (data: RegisterInput) => {
    try {
      store.setRequestLoading(true);
      const response = await authApi.post<GenericResponse>(
        "auth/register",
        data
      );
      toast.success(response.data.message, {
        position: "top-right",
      });
      store.setRequestLoading(false);
      navigate("/login");
    } catch (error: any) {
      store.setRequestLoading(false);
      const resMessage =
        (error.response &&
          error.response.data &&
          error.response.data.message) ||
        error.response.data.detail ||
        error.message ||
        error.toString();
      toast.error(resMessage, {
        position: "top-right",
      });
    }
  };

  const onSubmitHandler: SubmitHandler<RegisterInput> = (values) => {
    registerUser(values);
  };

  return (
    <section className="py-8 bg-ct-blue-600 min-h-screen grid place-items-center">
      <div className="w-full">
        <h1 className="text-4xl xl:text-6xl text-center font-[600] text-ct-yellow-600 mb-4">
          Welcome to CodevoWeb!
        </h1>
        <h2 className="text-lg text-center mb-4 text-ct-dark-200">
          Sign Up To Get Started!
        </h2>
        <FormProvider {...methods}>
          <form
            onSubmit={handleSubmit(onSubmitHandler)}
            className="max-w-md w-full mx-auto overflow-hidden shadow-lg bg-ct-dark-200 rounded-2xl p-8 space-y-5"
          >
            <FormInput label="Full Name" name="name" />
            <FormInput label="Email" name="email" type="email" />
            <FormInput label="Password" name="password" type="password" />
            <FormInput
              label="Confirm Password"
              name="passwordConfirm"
              type="password"
            />
            <span className="block">
              Already have an account?{" "}
              <Link to="/login" className="text-ct-blue-600">
                Login Here
              </Link>
            </span>
            <LoadingButton
              loading={store.requestLoading}
              textColor="text-ct-blue-600"
            >
              Sign Up
            </LoadingButton>
          </form>
        </FormProvider>
      </div>
    </section>
  );
};

export default RegisterPage;

Quite a lot is happening in the above code, let’s break it down. First, we imported all the necessary dependencies and created the registerSchema with Zod. Since we are dealing with TypeScript, we used the TypeOf<> type provided by Zod to infer the TypeScript types from the registerSchema.

Then, we evoked the useForm() hook and provided it with the interred TypeScript type which is the RegisterInput and the Zod schema. After that, we created a function that will be called by React-Hook-Form to submit the form data to the API when there is no validation error.

Since the FormInput is defined in a separate file, we used the FormProvider component provided by React-Hook-Form to make the form context available to the FormInput component.

User Login Component

This component will submit the login credentials to the API by making an Axios POST request to the /api/auth/login endpoint with the credentials added as a JSON object to the request body.

Similar to the RegisterPage, this component will also have a form that has email and password fields. The form validation will also be handled by React-Hook-Form and the validation rules will be defined with Zod.

src/pages/login.page.tsx


import { object, string, TypeOf } from "zod";
import { useEffect } from "react";
import { useForm, FormProvider, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import FormInput from "../components/FormInput";
import { LoadingButton } from "../components/LoadingButton";
import { toast } from "react-toastify";
import { Link, useLocation, useNavigate } from "react-router-dom";
import useStore from "../store";
import { authApi } from "../api/authApi";
import { ILoginResponse } from "../api/types";

const loginSchema = object({
  email: string()
    .min(1, "Email address is required")
    .email("Email Address is invalid"),
  password: string()
    .min(1, "Password is required")
    .min(8, "Password must be more than 8 characters")
    .max(32, "Password must be less than 32 characters"),
});

export type LoginInput = TypeOf<typeof loginSchema>;

const LoginPage = () => {
  const navigate = useNavigate();
  const location = useLocation();

  const methods = useForm<LoginInput>({
    resolver: zodResolver(loginSchema),
  });

  const store = useStore();

  const {
    reset,
    handleSubmit,
    formState: { isSubmitSuccessful },
  } = methods;

  useEffect(() => {
    if (isSubmitSuccessful) {
      reset();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [isSubmitSuccessful]);

  const loginUser = async (data: LoginInput) => {
    try {
      store.setRequestLoading(true);
      const {
        data: { user },
      } = await authApi.post<ILoginResponse>("/auth/login", data);
      store.setRequestLoading(false);
      store.setAuthUser(user);
      if (user.otp_enabled) {
        navigate("/login/validateOtp");
      } else {
        navigate("/profile");
      }
    } catch (error: any) {
      store.setRequestLoading(false);
      const resMessage =
        (error.response &&
          error.response.data &&
          error.response.data.message) ||
        error.response.data.detail ||
        error.message ||
        error.toString();
      toast.error(resMessage, {
        position: "top-right",
      });
    }
  };

  const onSubmitHandler: SubmitHandler<LoginInput> = (values) => {
    loginUser(values);
  };

  return (
    <section className="bg-ct-blue-600 min-h-screen grid place-items-center">
      <div className="w-full">
        <h1 className="text-4xl lg:text-6xl text-center font-[600] text-ct-yellow-600 mb-4">
          Welcome Back
        </h1>
        <h2 className="text-lg text-center mb-4 text-ct-dark-200">
          Login to have access
        </h2>
        <FormProvider {...methods}>
          <form
            onSubmit={handleSubmit(onSubmitHandler)}
            className="max-w-md w-full mx-auto overflow-hidden shadow-lg bg-ct-dark-200 rounded-2xl p-8 space-y-5"
          >
            <FormInput label="Email" name="email" type="email" />
            <FormInput label="Password" name="password" type="password" />

            <div className="text-right">
              <Link to="/forgotpassword" className="">
                Forgot Password?
              </Link>
            </div>
            <LoadingButton
              loading={store.requestLoading}
              textColor="text-ct-blue-600"
            >
              Login
            </LoadingButton>
            <span className="block">
              Need an account?{" "}
              <Link to="/register" className="text-ct-blue-600">
                Sign Up Here
              </Link>
            </span>
          </form>
        </FormProvider>
      </div>
    </section>
  );
};

export default LoginPage;

OTP Validation Component

Now that we are able to register and log in users, let’s create a component to submit the OTP token to the API. This component will make an Axios POST request to the /api/auth/otp/validate endpoint with the OTP token included in the request body.

src/pages/validate2fa.page.tsx


import { object, string, TypeOf } from "zod";
import { useEffect } from "react";
import { useForm, SubmitHandler } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { LoadingButton } from "../components/LoadingButton";
import { toast } from "react-toastify";
import { Link, useNavigate } from "react-router-dom";
import useStore from "../store";
import { authApi } from "../api/authApi";

const styles = {
  inputField: `form-control block w-full px-4 py-4 text-sm text-gray-700 bg-white bg-clip-padding border border-solid border-gray-300 rounded transition ease-in-out m-0 focus:text-gray-700 focus:bg-white focus:border-blue-600 focus:outline-none`,
};

const validate2faSchema = object({
  token: string().min(1, "Authentication code is required"),
});

export type Validate2faInput = TypeOf<typeof validate2faSchema>;

const Validate2faPage = () => {
  const navigate = useNavigate();
  const store = useStore();

  const {
    handleSubmit,
    setFocus,
    register,
    formState: { errors },
  } = useForm<Validate2faInput>({
    resolver: zodResolver(validate2faSchema),
  });

  const validate2fa = async (token: string) => {
    try {
      store.setRequestLoading(true);
      const {
        data: { otp_valid },
      } = await authApi.post<{ otp_valid: boolean }>("/auth/otp/validate", {
        token,
        user_id: store.authUser?.id,
      });
      store.setRequestLoading(false);
      if (otp_valid) {
        navigate("/profile");
      } else {
        navigate("/login");
      }
    } catch (error: any) {
      store.setRequestLoading(false);
      const resMessage =
        (error.response &&
          error.response.data &&
          error.response.data.message) ||
        error.response.data.detail ||
        error.message ||
        error.toString();
      toast.error(resMessage, {
        position: "top-right",
      });
    }
  };

  const onSubmitHandler: SubmitHandler<Validate2faInput> = (values) => {
    validate2fa(values.token);
  };

  useEffect(() => {
    setFocus("token");
  }, [setFocus]);

  useEffect(() => {
    if (!store.authUser) {
      navigate("/login");
    }
  }, []);

  return (
    <section className="bg-ct-blue-600 min-h-screen grid place-items-center">
      <div className="w-full">
        <h1 className="text-4xl lg:text-6xl text-center font-[600] text-ct-yellow-600 mb-4">
          Welcome Back
        </h1>
        <h2 className="text-lg text-center mb-4 text-ct-dark-200">
          Verify the Authentication Code
        </h2>
        <form
          onSubmit={handleSubmit(onSubmitHandler)}
          className="max-w-md w-full mx-auto overflow-hidden shadow-lg bg-ct-dark-200 rounded-2xl p-8 space-y-5"
        >
          <h2 className="text-center text-3xl font-semibold text-[#142149]">
            Two-Factor Authentication
          </h2>
          <p className="text-center text-sm">
            Open the two-step verification app on your mobile device to get your
            verification code.
          </p>
          <input
            {...register("token")}
            className={styles.inputField}
            placeholder="Authentication Code"
          />
          <p className="mt-2 text-xs text-red-600">
            {errors.token ? errors.token.message : null}
          </p>

          <LoadingButton
            loading={store.requestLoading}
            textColor="text-ct-blue-600"
          >
            Authenticate
          </LoadingButton>
          <span className="block text-center">
            <Link to="/login" className="text-ct-blue-600">
              Back to basic login
            </Link>
          </span>
        </form>
      </div>
    </section>
  );
};

export default Validate2faPage;

Step 5 – Create Other Components

Apart from the authentication components, let’s create these two React components to display a welcome message and the user’s profile information.

Home Page

The home page component will be rendered to show a welcome message on the root route.

src/pages/home.page.tsx


const HomePage = () => {
  return (
    <>
      <section className="bg-ct-blue-600 min-h-screen pt-20">
        <div className="max-w-4xl mx-auto bg-ct-dark-100 rounded-md h-[20rem] flex justify-center items-center">
          <p className="text-3xl font-semibold">
            Welcome to Two-Factor Auth with React.js
          </p>
        </div>
      </section>
    </>
  );
};

export default HomePage;

Profile Page

This component will display the user’s credentials after the user completes the authentication process. It will also have a button to enable and disable the 2FA feature in the application.

src/pages/profile.page.tsx


import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "react-toastify";
import { authApi } from "../api/authApi";
import { IUser } from "../api/types";
import TwoFactorAuth from "../components/TwoFactorAuth";
import useStore from "../store";

const ProfilePage = () => {
  const [secret, setSecret] = useState({
    otpauth_url: "",
    base32: "",
  });
  const [openModal, setOpenModal] = useState(false);
  const navigate = useNavigate();
  const store = useStore();
  const user = store.authUser;

  const generateQrCode = async (user_id: string) => {
    try {
      store.setRequestLoading(true);
      const response = await authApi.post<{
        otpauth_url: string;
        base32: string;
      }>("/auth/otp/generate", { user_id });
      store.setRequestLoading(false);

      if (response.status === 200) {
        setOpenModal(true);
        console.log({
          base32: response.data.base32,
          otpauth_url: response.data.otpauth_url,
        });
        setSecret({
          base32: response.data.base32,
          otpauth_url: response.data.otpauth_url,
        });
      }
    } catch (error: any) {
      store.setRequestLoading(false);
      const resMessage =
        (error.response &&
          error.response.data &&
          error.response.data.message) ||
        error.response.data.detail ||
        error.message ||
        error.toString();
      toast.error(resMessage, {
        position: "top-right",
      });
    }
  };

  const disableTwoFactorAuth = async (user_id: string) => {
    try {
      store.setRequestLoading(true);
      const {
        data: { user },
      } = await authApi.post<{
        otp_disabled: boolean;
        user: IUser;
      }>("/auth/otp/disable", { user_id });
      store.setRequestLoading(false);
      store.setAuthUser(user);
      toast.warning("Two Factor Authentication Disabled", {
        position: "top-right",
      });
    } catch (error: any) {
      store.setRequestLoading(false);
      const resMessage =
        (error.response &&
          error.response.data &&
          error.response.data.message) ||
        error.response.data.detail ||
        error.message ||
        error.toString();
      toast.error(resMessage, {
        position: "top-right",
      });
    }
  };

  useEffect(() => {
    console.log(store.authUser);
    if (!store.authUser) {
      navigate("/login");
    }
  }, []);

  return (
    <>
      <section className="bg-ct-blue-600  min-h-screen pt-10">
        <div className="max-w-4xl p-12 mx-auto bg-ct-dark-100 rounded-md h-[20rem] flex gap-20 justify-center items-start">
          <div className="flex-grow-2">
            <h1 className="text-2xl font-semibold">Profile Page</h1>
            <div className="mt-8">
              <p className="mb-4">ID: {user?.id}</p>
              <p className="mb-4">Name: {user?.name}</p>
              <p className="mb-4">Email: {user?.email}</p>
            </div>
          </div>
          <div>
            <h3 className="text-2xl font-semibold">
              Mobile App Authentication (2FA)
            </h3>
            <p className="mb-4">
              Secure your account with TOTP two-factor authentication.
            </p>
            {store.authUser?.otp_enabled ? (
              <button
                type="button"
                className="focus:outline-none text-white bg-purple-700 hover:bg-purple-800 focus:ring-4 focus:ring-purple-300 font-medium rounded-lg text-sm px-5 py-2.5 mb-2"
                onClick={() => disableTwoFactorAuth(user?.id!)}
              >
                Disable 2FA
              </button>
            ) : (
              <button
                type="button"
                className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none"
                onClick={() => generateQrCode(user?.id!)}
              >
                Setup 2FA
              </button>
            )}
          </div>
        </div>
      </section>
      {openModal && (
        <TwoFactorAuth
          base32={secret.base32}
          otpauth_url={secret.otpauth_url}
          user_id={store.authUser?.id!}
          closeModal={() => setOpenModal(false)}
        />
      )}
    </>
  );
};

export default ProfilePage;

Step 6 – Setup Routing in React.js

Now that we have all the pages defined, it’s time to set up routing in the app. To begin, create a src/components/Layout.tsx file and add this code.

src/components/Layout.tsx


import { Outlet } from "react-router-dom";
import Header from "./Header";

const Layout = () => {
  return (
    <>
      <Header />
      <Outlet />
    </>
  );
};

export default Layout;

Next, create a src/router/index.tsx file and add the following code snippets to set up the page routing using the object syntax introduced by React-Router-Dom v6.

src/router/index.tsx


import type { RouteObject } from "react-router-dom";
import Layout from "../components/Layout";
import HomePage from "../pages/home.page";
import LoginPage from "../pages/login.page";
import ProfilePage from "../pages/profile.page";
import RegisterPage from "../pages/register.page";
import Validate2faPage from "../pages/validate2fa.page";

const authRoutes: RouteObject = {
  path: "*",
  children: [
    {
      path: "login",
      children: [
        {
          path: "",
          element: <LoginPage />,
        },
        {
          path: "validateOtp",
          element: <Validate2faPage />,
        },
      ],
    },
    {
      path: "register",
      element: <RegisterPage />,
    },
  ],
};

const normalRoutes: RouteObject = {
  path: "*",
  element: <Layout />,
  children: [
    {
      index: true,
      element: <HomePage />,
    },
    {
      path: "profile",
      element: <ProfilePage />,
    },
  ],
};

const routes: RouteObject[] = [authRoutes, normalRoutes];

export default routes;

Step 7 – Configure the App

Since we have the route object defined, let’s use the useRoute() hook provided by React-Router-Dom to render the element that matches the current location.

src/App.tsx


import { useRoutes } from "react-router-dom";
import routes from "./router";

function App() {
  const content = useRoutes(routes);
  return content;
}

export default App;

Next, open the src/main.tsx file and replace its content with the following code:

src/main.tsx


import "./index.css";
import "react-toastify/dist/ReactToastify.css";
import { ToastContainer } from "react-toastify";
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import { BrowserRouter } from "react-router-dom";

ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
      <ToastContainer />
    </BrowserRouter>
  </React.StrictMode>
);

Now start the Vite development server and test the 2FA verification system against the API.

Conclusion

In this article, you learned how to implement two-factor authentication (2FA) in React.js. Also, you learned how to:

  • Validate forms with React-Hook-Form and Zod
  • Register users with React
  • Sign-in registered users
  • Generate QR Codes in React.js
  • Implement 2FA in React

You can find the React 2FA project source code from this GitHub repository.