DEV Community

Daanish2003
Daanish2003

Posted on

Two-Factor Authentication Using Better_Auth, Next.js, Prisma, ShadCN, and Resend

In this guide, we will set up Two-Factor Authentication (2FA) for a modern web application. Using a powerful stack of tools and frameworks, you'll implement a robust and secure email-based 2FA system seamlessly integrated into your full-stack application.

By the end of this guide, you'll have a fully functional system that enables users to verify their identity using a one-time password (OTP) for enhanced security.

Tech Stack

Here’s the technology stack we’ll be using:

  • Better_Auth v1: A lightweight and extensible TypeScript authentication library.
  • Next.js: A powerful React framework for building server-rendered applications.
  • Prisma: A modern ORM for efficient and type-safe database interaction.
  • ShadCN: A utility-first component library for rapid UI development.
  • TailwindCSS: A popular CSS framework for building modern user interfaces.
  • Resend: A reliable email service for sending OTPs.

Prerequisites

Before proceeding, ensure you have the following ready:

  1. Node.js (LTS version) installed.
  2. A package manager like npm, yarn, or pnpm (we'll use pnpm in this guide).
  3. A PostgreSQL database instance (local or hosted, such as Supabase or PlanetScale).
    • If you're working locally, Docker is a great way to set this up.
  4. Familiarity with TypeScript, Next.js, and Prisma.

Cloning the Starter Project:

This guide builds upon existing functionality, including email-password authentication and email verification. You can either start from scratch by referring to the EmailPassword guide and Email Verification guide or clone the project with the following command:

git clone -b feat-reset-password https://github.com/Daanish2003/better_auth_nextjs.git  
Enter fullscreen mode Exit fullscreen mode

Navigate to the project directory and install dependencies:

pnpm install  
Enter fullscreen mode Exit fullscreen mode

Configure the .env File

Create a .env file in the root of your project and add the following configuration:

# Authentication settings
BETTER_AUTH_SECRET="your-secret-key" # Replace with a secure key
BETTER_AUTH_URL="http://localhost:3000"
NEXT_PUBLIC_APP_URL="http://localhost:3000"

# Database settings
POSTGRES_PASSWORD="your-password"
POSTGRES_USER="your-username"
DATABASE_URL="postgresql://your-username:your-password@localhost:5432/mydb?schema=public"

# Resend API Key
RESEND_API_KEY="your-resend-api-key"
Enter fullscreen mode Exit fullscreen mode

If you're using Docker for PostgreSQL, run:

docker compose up -d
Enter fullscreen mode Exit fullscreen mode

Step 1: Install ShadCN Components

ShadCN simplifies UI development with pre-built, customizable components. Install the required components:

pnpx shadcn@latest add dialog switch input-otp
Enter fullscreen mode Exit fullscreen mode

Step 2: Update Prisma Schema

Extend the prisma/schema.prisma file to include a TwoFactor model for 2FA data:

model User {
  // note just update it don't replace it
  twoFactor  TwoFactor[] // Add the twoFactor column to user table
}

model TwoFactor {
  id          String @id @default(cuid())
  secret      String
  backupCodes String
  userId      String
  user        User   @relation(fields: [userId], references: [id], onDelete: Cascade)

  @@map("twoFactor")
}
Enter fullscreen mode Exit fullscreen mode

Run the following commands to update the database schema:

pnpm prisma generate
pnpm prisma migrate dev --name add-two-factor
Enter fullscreen mode Exit fullscreen mode

Step 3: Integrate 2FA Using Better_Auth

Backend Configuration

Update lib/auth.ts to include the twoFactor plugin:

import { betterAuth } from "better-auth";
import { twoFactor } from "better-auth/plugins";

// Configure the authentication module
export const auth = betterAuth({
  // Additional authentication options can go here

  // Register plugins for enhanced functionality
  plugins: [
    // Two-Factor Authentication (2FA) plugin configuration
    twoFactor({
      // Configuration options for OTP (One-Time Password) handling
      otpOptions: {
        /**
         * Defines how OTPs are sent to users.
         * In this case, we are using the Resend service to send OTP emails.
         * 
         * @param user - The user object containing user details like email.
         * @param otp - The one-time password to be sent to the user.
         */
        async sendOTP({ user, otp }) {
          await resend.emails.send({
            from: 'Acme <onboarding@resend.dev>', // Sender email and display name
            to: user.email, // Recipient's email address
            subject: "Two-Factor Authentication (2FA)", // Email subject
            html: `Your OTP is <b>${otp}</b>`, // Email content with the OTP
          });
        },
      },

      // Bypass OTP verification during 2FA setup (for a smoother user experience)
      skipVerificationOnEnable: true,
    }),
  ],
});

Enter fullscreen mode Exit fullscreen mode

Frontend Configuration

In lib/auth-client.ts, add the 2FA client plugin:

import { createAuthClient } from "better-auth/client";
import { twoFactorClient } from "better-auth/plugins";

// Create an instance of the authentication client
export const authClient = createAuthClient({
  // The base URL of the application
  baseURL: process.env.NEXT_PUBLIC_APP_URL, 
  /**
   * Explanation of `baseURL`:
   * - Used to define the base API endpoint for authentication requests.
   * - The value is fetched from an environment variable (`NEXT_PUBLIC_APP_URL`) 
   *   to maintain flexibility across different environments (e.g., development, staging, production).
   */

  // Register plugins to extend the functionality of the authentication client
  plugins: [
    twoFactorClient(), 
    /**
     * `twoFactorClient` Plugin:
     * - Enables support for Two-Factor Authentication (2FA) on the client side.
     * - Handles 2FA-related tasks, such as requesting OTP verification.
     */
  ],
});

Enter fullscreen mode Exit fullscreen mode

Step 4: Build the UI for 2FA

Create Zod Validation Schema

Create a password-schema.ts file under src/helpers/zod/:

import { z } from "zod";

// Define a schema for validating password input
export const PasswordSchema = z.object({
  password: z
    .string() // Ensures the input is a string
    .min(8, { message: "Password must be at least 8 characters long" }) 
    /**
     * Validates that the password has a minimum length of 8 characters.
     * Custom error message provided to guide the user.
     */
    .max(20, { message: "Password must be at most 20 characters long" }),
    /**
     * Validates that the password does not exceed 20 characters.
     * Ensures user-friendly error messaging for invalid input.
     */
});

Enter fullscreen mode Exit fullscreen mode

Add Settings Component

Add the Settings component under components/auth to handle enabling/disabling 2FA. Use the code provided earlier.

"use client";

import React, { useState } from "react";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
import { Button } from "../ui/button";
import { Card, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Switch } from "../ui/switch";
import { authClient, useSession } from "@/lib/auth-client";
import { Input } from "../ui/input";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "../ui/form";
import { useForm } from "react-hook-form";
import { PasswordSchema } from "@/helpers/zod/password-schema";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { FormSuccess } from "../form-success";
import FormError from "../form-error";
import { useAuthState } from "@/hooks/useAuthState";
import { Settings as UserSettings } from "lucide-react";

const Settings = () => {
  const { data } = useSession(); // Fetch session data to check if 2FA is enabled
  const [open, setOpen] = useState<boolean>(false); // Manages the dialog open/close state
  const { error, success, loading, setLoading, setSuccess, setError, resetState } = useAuthState();

  // React Hook Form setup for password validation
  const form = useForm<z.infer<typeof PasswordSchema>>({
    resolver: zodResolver(PasswordSchema), // Resolves validation rules using Zod
    defaultValues: {
      password: "", // Default value for the password field
    },
  });

  // If two-factor authentication status is unknown, do not render the component
  if (data?.user.twoFactorEnabled === null) {
    return null;
  }

  /**
   * Handles form submission for enabling/disabling two-factor authentication.
   * @param values - The form data, including the user's password
   */
  const onSubmit = async (values: z.infer<typeof PasswordSchema>) => {
    if (data?.user.twoFactorEnabled === false) {
      // Enable 2FA
      await authClient.twoFactor.enable(
        { password: values.password },
        {
          onRequest: () => {
            resetState();
            setLoading(true); // Show loading state during request
          },
          onResponse: () => {
            setLoading(false); // Stop loading after response
          },
          onSuccess: () => {
            setSuccess("Enabled two-factor authentication"); // Success message
            setTimeout(() => {
              setOpen(false); // Close dialog
              resetState();
              form.reset(); // Clear form fields
            }, 1000); // Delay to allow user to see the success message
          },
          onError: (ctx) => {
            setError(ctx.error.message); // Display error message
          },
        }
      );
    }

    if (data?.user.twoFactorEnabled === true) {
      // Disable 2FA
      await authClient.twoFactor.disable(
        { password: values.password },
        {
          onRequest: () => {
            resetState();
            setLoading(true); // Show loading state during request
          },
          onResponse: () => {
            setLoading(false); // Stop loading after response
          },
          onSuccess: () => {
            setSuccess("Disabled two-factor authentication"); // Success message
            setTimeout(() => {
              setOpen(false); // Close dialog
              resetState();
              form.reset(); // Clear form fields
            }, 1000);
          },
          onError: (ctx) => {
            setError(ctx.error.message); // Display error message
          },
        }
      );
    }
  };

  return (
    <>
      {/* Main settings dialog */}
      <Dialog>
        <DialogTrigger asChild>
          <Button variant={"default"}>
            <UserSettings />
          </Button>
        </DialogTrigger>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Settings</DialogTitle>
            <DialogDescription>Make changes to your settings here</DialogDescription>
          </DialogHeader>
          <Card>
            <CardHeader className="p-4 flex flex-row justify-between">
              <div>
                <CardTitle className="text-sm">Enable 2FA</CardTitle>
                <CardDescription className="text-xs">
                  Toggle to enable or disable two-factor authentication
                </CardDescription>
              </div>
              {/* Toggle switch for enabling/disabling 2FA */}
              <Switch
                checked={data?.user.twoFactorEnabled}
                onCheckedChange={() => {
                  setOpen(true);
                }}
              />
            </CardHeader>
          </Card>
        </DialogContent>
      </Dialog>

      {/* Dialog for password confirmation */}
      <Dialog
        open={open}
        onOpenChange={() => {
          setOpen(false);
        }}
      >
        <DialogContent>
          <DialogHeader>
            <DialogTitle>Confirm Selection</DialogTitle>
            <DialogDescription>Please enter your password to confirm the action</DialogDescription>
          </DialogHeader>
          <Form {...form}>
            <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
              <FormField
                control={form.control}
                name="password"
                render={({ field }) => (
                  <FormItem>
                    <FormLabel>Password</FormLabel>
                    <FormControl>
                      <Input
                        disabled={loading}
                        type="password"
                        placeholder="********"
                        {...field}
                      />
                    </FormControl>
                    <FormMessage />
                  </FormItem>
                )}
              />
              {/* Feedback messages */}
              <FormSuccess message={success} />
              <FormError message={error} />
              <Button
                type="submit"
                className="w-full mt-4"
                disabled={loading}
              >
                Submit
              </Button>
            </form>
          </Form>
        </DialogContent>
      </Dialog>
    </>
  );
};

export default Settings;

Enter fullscreen mode Exit fullscreen mode

Update app/page.tsx

To integrate the Settings and SignOut components into the homepage navigation, update the app/page.tsx file as shown below:

import Settings from "@/components/auth/settings";
import SignOut from "@/components/auth/sign-out";

export default function Home() {
  return (
    <nav className="flex items-center h-16 border-b px-12 justify-between">
      {/* Application title */}
      <span className="font-bold text-xl">Auth</span>
      {/* Right section with actions */}
      <div className="flex gap-x-2">
        {/* Sign-out button */}
        <SignOut />
        {/* Settings dropdown */}
        <Settings />
      </div>
    </nav>
  );
}

Enter fullscreen mode Exit fullscreen mode

Implement OTP Input Component

Customize input-otp.tsx for user-friendly OTP entry. Add your desired styles and logic for the OTP slots.

"use client"

import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { Dot } from "lucide-react"

import { cn } from "@/lib/utils"

const InputOTP = React.forwardRef<
  React.ElementRef<typeof OTPInput>,
  React.ComponentPropsWithoutRef<typeof OTPInput>
>(({ className, containerClassName, ...props }, ref) => (
  <OTPInput
    ref={ref}
    containerClassName={cn(
      "flex items-center gap-2 has-[:disabled]:opacity-50 justify-center", // Added justify-center to className
      containerClassName
    )}
    className={cn("disabled:cursor-not-allowed", className)}
    {...props}
  />
))
InputOTP.displayName = "InputOTP"

const InputOTPGroup = React.forwardRef<
  React.ElementRef<"div">,
  React.ComponentPropsWithoutRef<"div">
>(({ className, ...props }, ref) => (
  // Added the gap-x-3 for space between the slots
  <div ref={ref} className={cn("flex items-center gap-x-3", className)} {...props} />
))
InputOTPGroup.displayName = "InputOTPGroup"

const InputOTPSlot = React.forwardRef<
  React.ElementRef<"div">,
  React.ComponentPropsWithoutRef<"div"> & { index: number }
>(({ index, className, ...props }, ref) => {
  const inputOTPContext = React.useContext(OTPInputContext)
  const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]

  return (
  {/*Removed first:rounded-l-md last:rounded-r-md first:border-l*/}
  {/*Added border rounded-md*/}
  {/*Changed h-10 to h-12*/}
    <div
      ref={ref}
      className={cn(
        "relative flex h-12 w-10 items-center justify-center border border-y border-r border-input text-sm transition-all rounded-md",
        isActive && "z-10 ring-2 ring-ring ring-offset-background",
        className
      )}
      {...props}
    >
      {char}
      {hasFakeCaret && (
        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
          <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
        </div>
      )}
    </div>
  )
})
InputOTPSlot.displayName = "InputOTPSlot"

const InputOTPSeparator = React.forwardRef<
  React.ElementRef<"div">,
  React.ComponentPropsWithoutRef<"div">
>(({ ...props }, ref) => (
  <div ref={ref} role="separator" {...props}>
    <Dot />
  </div>
))
InputOTPSeparator.displayName = "InputOTPSeparator"

export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

Enter fullscreen mode Exit fullscreen mode

Create a requestOTP Helper Component

Create a requestOTP.tsfile inside the helpers/auth/ and paste the code inside it

import { authClient } from "@/lib/auth-client";

// Interface for the response returned by the OTP request
interface OTPResponse {
  data?: { status: boolean } | null; // Holds response data indicating the OTP request status
  error?: { message: string }; // Holds error details if the request fails
}

// Type alias for a successful response structure
type SuccessResponse = Pick<OTPResponse, "data">;

/**
 * Sends a request to generate and send a one-time password (OTP).
 *
 * @returns {Promise<OTPResponse>} - A promise resolving to either the response data or an error object.
 */
export const requestOTP = async (): Promise<OTPResponse> => {
  try {
    // Send OTP request using the authClient's two-factor authentication module
    const response: SuccessResponse = await authClient.twoFactor.sendOtp();

    // Return the successful response object
    return response;
  } catch (error: unknown) {
    console.error("Error requesting OTP:", error);

    // Return an error object with a user-friendly message
    return {
      error: { message: "Failed to request OTP. Please try again." },
    };
  }
};

Enter fullscreen mode Exit fullscreen mode

Create Two-Factor Verification Component

Create a TwoFactor component for OTP verification. Refer to the implementation provided earlier.

"use client";

import React from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useRouter } from "next/navigation";

import CardWrapper from "../card-wrapper";
import { Form, FormField, FormItem, FormLabel, FormMessage } from "../ui/form";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "../ui/input-otp";
import { REGEXP_ONLY_DIGITS } from "input-otp";
import FormError from "../form-error";
import { Button } from "../ui/button";
import { FormSuccess } from "../form-success";

import { authClient } from "@/lib/auth-client";
import { twoFactorSchema } from "@/helpers/zod/two-factor-schema";
import { useAuthState } from "@/hooks/useAuthState";
import { requestOTP } from "@/helpers/auth/request-otp";

const TwoFactor: React.FC = () => {
    const router = useRouter();
    const {
        error, // Stores error message if any process fails
        success, // Stores success message if a process succeeds
        loading, // Indicates whether an operation is in progress
        setSuccess, // Sets the success message
        setError, // Sets the error message
        setLoading, // Toggles the loading state
        resetState, // Resets all success, error, and loading states
    } = useAuthState();

    const form = useForm<z.infer<typeof twoFactorSchema>>({
        mode: "onBlur", // Triggers validation on blur
        resolver: zodResolver(twoFactorSchema), // Uses Zod schema for validation
        defaultValues: { code: "" }, // Initializes the OTP code as an empty string
    });

    /**
     * Requests a new OTP to be sent to the user's email.
     */
    const handleResendOTP = async () => {
        resetState(); // Clear previous error/success messages
        setLoading(true); // Start loading state

        try {
            const response = await requestOTP(); // Makes a request to send OTP

            if (response?.data) {
                setSuccess("OTP has been sent to your email."); // Notify user on success
            } else if (response?.error) {
                setError(response.error.message); // Show error message if request fails
            }
        } catch (err) {
            console.error("Error requesting OTP:", err);
            setError("Something went wrong. Please try again."); // Catch unexpected errors
        } finally {
            setLoading(false); // End loading state
        }
    };

    /**
     * Handles the submission of the OTP code for verification.
     * @param values - The form values containing the OTP code.
     */
    const handleSubmit = async (values: z.infer<typeof twoFactorSchema>) => {
        resetState(); // Clear previous error/success messages
        setLoading(true); // Start loading state

        try {
            // Verify OTP using Better_Auth client
            await authClient.twoFactor.verifyOtp(
                { code: values.code }, // Pass the entered OTP code
                {
                    onRequest: () => setLoading(true), // Start loading when request begins
                    onResponse: () => setLoading(false), // End loading after response
                    onSuccess: () => {
                        setSuccess("OTP validated successfully."); // Notify user of success
                        router.replace("/"); // Redirect to the homepage
                    },
                    onError: (ctx) => setError(ctx.error.message), // Show error message if verification fails
                }
            );
        } catch (err) {
            console.error("Error verifying OTP:", err);
            setError("Unable to verify OTP. Please try again."); // Catch unexpected errors
        } finally {
            setLoading(false); // End loading state
        }
    };

    return (
        <CardWrapper
            cardTitle="Two-Factor Authentication"
            cardDescription="Verify your identity with a one-time password." // Instructional text
            cardFooterDescription="Entered the wrong email?" // Text for footer message
            cardFooterLink="/login" // Link to the login page
            cardFooterLinkTitle="Login" // Footer link text
        >
            <Form {...form}>
                <form onSubmit={form.handleSubmit(handleSubmit)}>
                    {/* Form field for entering the OTP */}
                    <FormField
                        control={form.control}
                        name="code"
                        render={({ field }) => (
                            <FormItem>
                                <FormLabel>One-Time Password</FormLabel>
                                {/* OTP Input field accepting only digits */}
                                <InputOTP
                                    maxLength={6} // Maximum of 6 digits
                                    pattern={REGEXP_ONLY_DIGITS} // Allows only numeric input
                                    {...field}
                                    disabled={loading} // Disables input during loading
                                >
                                    <InputOTPGroup>
                                        {Array.from({ length: 6 }).map((_, index) => (
                                            <InputOTPSlot key={index} index={index} />
                                        ))}
                                    </InputOTPGroup>
                                </InputOTP>
                                <FormMessage />
                            </FormItem>
                        )}
                    />
                    {/* Button for resending the OTP */}
                    <Button
                        onClick={handleResendOTP}
                        variant="link"
                        className="text-xs underline ml-60" // Align to the right
                        disabled={loading} // Disabled during loading
                    >
                        Resend OTP
                    </Button>
                    <FormError message={error} /> {/* Displays error messages */}
                    <FormSuccess message={success} /> {/* Displays success messages */}
                    {/* Button to submit the OTP */}
                    <Button
                        type="submit"
                        disabled={loading} // Disabled during loading
                        className="w-full mt-4" // Full-width button with margin
                    >
                        Verify
                    </Button>
                </form>
            </Form>
        </CardWrapper>
    );
};

export default TwoFactor;

Enter fullscreen mode Exit fullscreen mode

Create TwoFactorPage

Create a folder named two-factor inside /app/(auth)/ folder and then create page.tsx and import two-factor component inside page.tsx

import TwoFactor from '@/components/auth/two-factor'
import React from 'react'
const TwoFactorPage = () => {
  return (
    <TwoFactor />
  )
}
export default TwoFactorPage
Enter fullscreen mode Exit fullscreen mode

Step 5: Run Your Application

Now that your setup is complete, it's time to test your implementation. Follow these steps to verify the two-factor authentication system:

  1. Start the development server:

    pnpm dev
    
  2. Access your application at http://localhost:3000/signin.

  3. Sign In:

    Log into your account using your email and password.

  4. Enable Two-Factor Authentication (2FA):

    • Navigate to Settings in your account dashboard.
    • Toggle the Enable 2FA option.
    • Enter your password to confirm and enable two-factor authentication.
  5. Test the Logout and Login Flow:

    • Log out of your account.
    • Try logging back in with your email and password.
    • Check your email for the OTP sent as part of the 2FA process.
  6. Validate OTP:

    • Enter the OTP in the provided input field on the 2FA page.
    • Verify that the system accepts the OTP and logs you into your account successfully.
  7. Disable Two-Factor Authentication (2FA):

    • Navigate to Settings in your account dashboard.
    • Toggle the Disable 2FA option.
    • Enter your password to confirm and enable two-factor authentication.

ScreenShots:

Image description

Image description

Image description

Image description

Conclusion

Congratulations! 🎉 You’ve successfully implemented Two-Factor Authentication using Better_Auth, Next.js, Prisma, ShadCN, and Resend. This setup provides an extra layer of security to your application, enhancing user trust and safeguarding their accounts.

Feel free to explore additional features, such as backup codes or app-based OTPs, to further strengthen your 2FA system.

Happy coding!

Reference Links:

Forgot and ResetPassword using BetterAuth: https://dev.to/daanish2003/forgot-and-reset-password-using-betterauth-nextjs-and-resend-ilj

Email Verification Blog: https://dev.to/daanish2003/email-verification-using-betterauth-nextjs-and-resend-37gn

Email And Password with Better_Auth: https://dev.to/daanish2003/email-and-password-auth-using-betterauth-nextjs-prisma-shadcn-and-tailwindcss-hgc

OAuth Blog: https://dev.to/daanish2003/oauth-using-betterauth-nextjs-prisma-shadcn-and-tailwindcss-45bp

Better_Auth Docs: https://www.better-auth.com/

pnpm Docs: https://pnpm.io/

Docker Docs: https://docs.docker.com/

Prisma Docs: https://www.prisma.io/docs/getting-started

Shadcn Docs: https://ui.shadcn.com/

Next.js Docs: https://nextjs.org/

Tailwindcss Docs: https://tailwindcss.com/

Github repository: https://github.com/Daanish2003/better_auth_nextjs

Top comments (0)