Two-Factor Authentication Using Better_Auth, Next.js, Prisma, ShadCN, and Resend
Daanish2003
Posted on November 29, 2024
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:
- Node.js (LTS version) installed.
- A package manager like npm, yarn, or pnpm (we'll use
pnpm
in this guide). - 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.
- 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
Navigate to the project directory and install dependencies:
pnpm install
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"
If you're using Docker for PostgreSQL, run:
docker compose up -d
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
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")
}
Run the following commands to update the database schema:
pnpm prisma generate
pnpm prisma migrate dev --name add-two-factor
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,
}),
],
});
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.
*/
],
});
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.
*/
});
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;
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>
);
}
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 }
Create a requestOTP
Helper Component
Create a requestOTP.ts
file 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." },
};
}
};
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;
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
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:
-
Start the development server:
pnpm dev
Access your application at
http://localhost:3000/signin
.-
Sign In:
Log into your account using your email and password.
-
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.
-
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.
-
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.
-
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:
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
Posted on November 29, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
November 29, 2024