This guide provides a step-by-step walkthrough for adding robust SMS-based Two-Factor Authentication (2FA) or One-Time Password (OTP) verification to your Next.js application using NextAuth.js for session management and Plivo for reliable SMS delivery.
We'll build a secure authentication flow where users log in with credentials (email/password) and, if 2FA is enabled, must verify their identity using a code sent via SMS before gaining full access. This enhances account security significantly. We will cover setting up the project, integrating Plivo, modifying the NextAuth flow, handling OTP logic, managing user 2FA settings, and deploying the application.
Technologies Used:
- Next.js: React framework for full-stack web applications (App Router).
- NextAuth.js (v5/beta): Authentication solution for Next.js, handling sessions, providers, and callbacks.
- Plivo: Communications platform API for sending SMS messages.
- Prisma: Next-generation ORM for database access (example uses PostgreSQL, adaptable to others).
- Node.js: JavaScript runtime environment.
- Tailwind CSS: Utility-first CSS framework (default with
create-next-app
). - (Optional) shadcn/ui: UI component library used in examples.
System Architecture:
sequenceDiagram
participant User
participant Browser (Next.js Frontend)
participant NextAuth Middleware
participant NextAuth Callbacks (Server)
participant Database (Prisma)
participant Plivo API
User->>Browser (Next.js Frontend): Enters Email/Password on /login
Browser (Next.js Frontend)->>NextAuth Callbacks (Server): Submits Credentials via Server Action
NextAuth Callbacks (Server)->>Database (Prisma): Verify User & Password
alt User Found & Password Correct & 2FA Disabled
NextAuth Callbacks (Server)->>Database (Prisma): Fetch User Data
NextAuth Callbacks (Server)-->>Browser (Next.js Frontend): Create Session Cookie, Redirect to Dashboard
else User Found & Password Correct & 2FA Enabled
NextAuth Callbacks (Server)->>NextAuth Callbacks (Server): Generate OTP
NextAuth Callbacks (Server)->>Database (Prisma): Store OTP Hash & Expiry for User
NextAuth Callbacks (Server)->>Plivo API: Send OTP SMS to User's Phone
Plivo API-->>User: Delivers SMS with OTP
NextAuth Callbacks (Server)-->>Browser (Next.js Frontend): Redirect to /verify-otp page (with partial/pending state)
User->>Browser (Next.js Frontend): Enters OTP on /verify-otp
Browser (Next.js Frontend)->>NextAuth Callbacks (Server): Submits OTP via Server Action
NextAuth Callbacks (Server)->>Database (Prisma): Fetch Stored OTP Hash for User
NextAuth Callbacks (Server)->>NextAuth Callbacks (Server): Verify Submitted OTP against Stored Hash & Expiry
alt OTP Correct
NextAuth Callbacks (Server)->>Database (Prisma): Clear OTP Hash
NextAuth Callbacks (Server)-->>Browser (Next.js Frontend): Establish Full Session, Redirect to Dashboard
else OTP Incorrect/Expired
NextAuth Callbacks (Server)-->>Browser (Next.js Frontend): Show Error on /verify-otp
end
else Invalid Credentials
NextAuth Callbacks (Server)-->>Browser (Next.js Frontend): Show Error on /login
end
%% Subsequent Requests
Browser (Next.js Frontend)->>NextAuth Middleware: Request Protected Route (e.g., /dashboard)
NextAuth Middleware->>NextAuth Middleware: Check Session Cookie
alt Valid Full Session
NextAuth Middleware-->>Browser (Next.js Frontend): Allow Access
else No/Invalid/Partial Session
NextAuth Middleware-->>Browser (Next.js Frontend): Redirect to /login (or /verify-otp if pending)
end
%% Enable/Disable 2FA (Example on /profile page)
User->>Browser (Next.js Frontend): Clicks Enable/Disable 2FA on /profile
Browser (Next.js Frontend)->>NextAuth Callbacks (Server): Submits Request via Server Action
NextAuth Callbacks (Server)->>Database (Prisma): Update user.twoFactorEnabled flag
NextAuth Callbacks (Server)-->>Browser (Next.js Frontend): Confirm Success/Failure
Prerequisites:
- Node.js (LTS version recommended) and npm/pnpm/yarn.
- A Plivo account with available credits.
- A Plivo phone number capable of sending SMS messages (purchased through the Plivo console).
- A database instance (e.g., PostgreSQL, MySQL, SQLite). This guide uses PostgreSQL.
- Basic understanding of Next.js, React, and asynchronous JavaScript.
- (Optional) Familiarity with
shadcn/ui
for UI components, or adapt examples to your preferred library.
Final Outcome:
A Next.js application where users can:
- Register and log in using email and password.
- Optionally enable SMS-based 2FA on their profile.
- Be prompted for an SMS OTP during login if 2FA is enabled.
- Securely access protected dashboard routes only after successful primary login and OTP verification (if applicable).
1. Setting up the Project
Let's initialize a new Next.js project and install the necessary dependencies.
1.1 Create Next.js App:
Open your terminal and run:
npx create-next-app@latest next-plivo-2fa --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd next-plivo-2fa
This command scaffolds a new Next.js project using the App Router, TypeScript, Tailwind CSS, and ESLint.
1.2 Install Dependencies:
Install NextAuth, Prisma, the Plivo SDK, and utility libraries:
npm install next-auth@beta @auth/prisma-adapter prisma
npm install plivo-node bcrypt zod otp-generator
npm install -D @types/bcrypt @types/otp-generator
# Optional: If using shadcn/ui for UI components
# npx shadcn-ui@latest init
# npx shadcn-ui@latest add button input label
next-auth@beta
: Core library for authentication. (Use beta for App Router compatibility).@auth/prisma-adapter
: NextAuth adapter for Prisma.prisma
: ORM for database interaction.plivo-node
: Official Plivo Node.js SDK.bcrypt
: Library for hashing passwords.zod
: Schema validation library.otp-generator
: Utility to generate secure OTPs.@types/*
: TypeScript definitions for development.
1.3 Initialize Prisma:
Set up Prisma for database management:
npx prisma init --datasource-provider postgresql
This creates:
prisma/schema.prisma
: Your database schema definition file..env
: An environment file (Prisma addsDATABASE_URL
).
1.4 Configure Environment Variables:
Open the .env
file created in the root of your project and add the following variables. Obtain the Plivo credentials from your Plivo Console (Dashboard -> API Keys & Credentials).
# .env
# Database (Prisma will add this during init - update with your actual connection string)
DATABASE_URL="postgresql://user:password@host:port/database?sslmode=require" # Example for PostgreSQL
# NextAuth Configuration
# Generate a strong secret. This is crucial for security.
# Any sufficiently long, random string will work.
# Example generation command (Linux/macOS with OpenSSL): openssl rand -base64 32
NEXTAUTH_SECRET="YOUR_NEXTAUTH_SECRET"
NEXTAUTH_URL="http://localhost:3000" # Use your production URL in deployment
# Plivo Credentials
PLIVO_AUTH_ID="YOUR_PLIVO_AUTH_ID"
PLIVO_AUTH_TOKEN="YOUR_PLIVO_AUTH_TOKEN"
PLIVO_PHONE_NUMBER="+1##########" # Your Plivo SMS-enabled number in E.164 format
# OTP Settings (Optional - Defaults can be set in code)
OTP_EXPIRY_MINUTES=5 # How long an OTP is valid
Explanation:
DATABASE_URL
: Connection string for your database. Adjust based on your provider.NEXTAUTH_SECRET
: A random string used to encrypt JWTs and session cookies. Crucial for security. Generate a strong one and keep it secret.NEXTAUTH_URL
: The canonical URL of your application. Required by NextAuth, especially for callbacks and redirects.PLIVO_AUTH_ID
/TOKEN
: Your Plivo API credentials. Keep these secret.PLIVO_PHONE_NUMBER
: The Plivo number used to send SMS.OTP_EXPIRY_MINUTES
: Defines the validity period for generated OTPs.
1.5 Define Database Schema:
Update prisma/schema.prisma
to include fields required by NextAuth, Prisma Adapter, and our custom 2FA logic.
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql" // Or your chosen provider
url = env("DATABASE_URL")
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
// Custom field to track 2FA verification status within a session
twoFactorVerified Boolean? @default(false)
}
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String? // For Credentials provider
accounts Account[]
sessions Session[]
// Custom Fields for 2FA
phoneNumber String? @unique // Store user's verified phone number for OTP
twoFactorEnabled Boolean @default(false)
twoFactorSecret String? // Store hashed OTP or related secret
twoFactorExpiry DateTime? // Store OTP expiry timestamp
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
Key Changes:
- Standard NextAuth models (
Account
,Session
,User
,VerificationToken
). - Added
password
field toUser
for the Credentials provider. - Added custom fields to
User
:phoneNumber
: To store the user's validated phone number for sending OTPs.twoFactorEnabled
: Boolean flag to track if 2FA is active for the user.twoFactorSecret
: To store the hashed OTP temporarily during verification. Do not store the plain OTP.twoFactorExpiry
: Timestamp indicating when the current OTP expires.
- Added
twoFactorVerified
field toSession
: This flag within the active session indicates if the second factor has been successfully verified during the current login attempt.
1.6 Apply Database Migrations:
Generate and apply the initial database migration:
npx prisma migrate dev --name init
This command:
- Creates SQL migration files based on your
schema.prisma
. - Applies the migration to your database.
- Generates the Prisma Client based on your schema.
1.7 Project Structure Overview:
Your src
directory should look something like this:
src/
├── app/ # Next.js App Router pages and layouts
│ ├── api/ # API routes (including NextAuth catch-all)
│ ├── (auth)/ # Route group for auth pages (login, register, verify-otp)
│ │ ├── login/
│ │ ├── register/
│ │ └── verify-otp/
│ ├── (protected)/ # Route group for authenticated pages
│ │ ├── dashboard/
│ │ └── layout.tsx # Layout protecting routes in this group
│ ├── layout.tsx # Root layout
│ └── page.tsx # Home page
├── components/ # Reusable UI components
│ ├── ui/ # (If using shadcn/ui) Generated components
│ └── auth/ # Authentication specific components
├── lib/ # Utility functions, constants, Prisma client, etc.
│ ├── prisma.ts # Prisma client instance
│ ├── actions.ts # Server Actions
│ ├── auth.ts # NextAuth configuration (main)
│ ├── auth.config.ts # NextAuth configuration (callbacks, pages - used by middleware)
│ ├── plivo.ts # Plivo client setup and functions
│ └── utils.ts # General utility functions (e.g., OTP generation)
└── middleware.ts # Next.js Middleware for route protection
This structure separates concerns, placing authentication pages in an (auth)
group and protected pages in a (protected)
group, managed by middleware and layouts.
2. Implementing Core Functionality (Authentication Flow)
Now, let's configure NextAuth, set up the Plivo client, and implement the core login, registration, and OTP verification logic.
2.1 Configure Prisma Client:
Create a singleton instance of the Prisma client.
// src/lib/prisma.ts
import { PrismaClient } from '@prisma/client';
declare global {
// eslint-disable-next-line no-var
var prisma: PrismaClient | undefined;
}
export const prisma = global.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma;
}
2.2 Configure Plivo Client:
Set up the Plivo client using credentials from environment variables.
// src/lib/plivo.ts
import * as plivo from 'plivo';
const authId = process.env.PLIVO_AUTH_ID;
const authToken = process.env.PLIVO_AUTH_TOKEN;
const plivoPhoneNumber = process.env.PLIVO_PHONE_NUMBER;
let plivoClient: plivo.Client | null = null;
if (!authId || !authToken || !plivoPhoneNumber) {
// In production, log a clear error. In development, a warning is acceptable
// as SMS might not be needed for all dev tasks.
if (process.env.NODE_ENV === 'production') {
console.error('Plivo credentials or phone number missing in environment variables. SMS sending will fail.');
// Consider throwing an error here in production if SMS is absolutely critical
// throw new Error('Plivo configuration is incomplete.');
} else {
console.warn('Plivo credentials or phone number missing. SMS functionality will be disabled/simulated.');
}
} else {
// Initialize client only if credentials are available
plivoClient = new plivo.Client(authId, authToken);
}
export const sourcePhoneNumber = plivoPhoneNumber;
/**
* Sends an SMS message using Plivo.
* @param to The recipient's phone number in E.164 format.
* @param text The message body.
* @returns Promise resolving with the Plivo API response or rejecting with an error.
*/
export async function sendSms(to: string, text: string) {
if (!plivoClient || !sourcePhoneNumber) {
// Log error or simulate success in development
if (process.env.NODE_ENV !== 'production') {
console.log(`SIMULATING SMS to ${to}: ${text}`);
// Return a shape similar to Plivo's success response for consistency
return Promise.resolve({
message: 'SMS simulated',
messageUuid: ['simulated-uuid-' + Math.random().toString(36).substring(7)],
apiId: 'simulated-api-id'
});
}
// In production, this should ideally not be reached if the check above is strict,
// but throw an error just in case.
console.error('Plivo client not initialized or source number missing. Cannot send SMS.');
throw new Error('SMS service is not configured.');
}
try {
const response = await plivoClient.messages.create(
sourcePhoneNumber, // src
to, // dst
text // text
);
// Avoid logging the entire response object in production if it contains sensitive info
console.log('Message sent successfully via Plivo. Message UUID:', response.messageUuid);
return response;
} catch (error) {
console.error('Error sending SMS via Plivo:', error);
throw error; // Re-throw the error to be handled by the caller
}
}
2.3 Configure NextAuth.js:
We need two configuration files for NextAuth v5: auth.config.ts
(used by middleware) and auth.ts
(main configuration).
First, define the extended Session and JWT types using module augmentation. This is typically done in auth.ts
or a dedicated types file.
// src/lib/auth.ts (Add these declarations at the top or in a separate d.ts file)
import { DefaultSession } from 'next-auth';
import { JWT as NextAuthJWT } from 'next-auth/jwt';
// Extend Session type
declare module 'next-auth' {
interface Session {
twoFactorVerified?: boolean; // Add custom property
user: {
id: string; // Ensure id is always present
} & DefaultSession['user']; // Keep default user properties
}
}
// Extend JWT type
declare module 'next-auth/jwt' {
interface JWT {
twoFactorVerified?: boolean; // Add custom property
id?: string; // Ensure id is present
}
}
auth.config.ts
(for Middleware):
This file contains configuration needed before the full NextAuth initialization, primarily for middleware checks.
// src/lib/auth.config.ts
import type { NextAuthConfig } from 'next-auth';
import { Session } from 'next-auth'; // Import base Session type
import { JWT } from 'next-auth/jwt'; // Import base JWT type
export const authConfig = {
pages: {
signIn: '/login', // Redirect users to `/login` if required
// error: '/auth/error', // Optional: Custom error page
},
callbacks: {
// This callback is crucial for protecting routes via Middleware
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
// Use the augmented Session type implicitly now
const session = auth;
const isTwoFactorVerified = session?.twoFactorVerified ?? false;
const isOnDashboard = nextUrl.pathname.startsWith('/dashboard');
const isOnVerifyOtp = nextUrl.pathname === '/verify-otp';
if (isOnDashboard) {
if (isLoggedIn) {
// If user is logged in but hasn't completed 2FA for this session, redirect to verify
if (session.user && !isTwoFactorVerified) {
// console.log(""Middleware: User logged in but 2FA not verified. Redirecting to /verify-otp."");
return Response.redirect(new URL('/verify-otp', nextUrl.origin));
}
// If logged in and 2FA verified (or not needed), allow access
return true;
}
return false; // Redirect unauthenticated users to login page
} else if (isOnVerifyOtp) {
// Allow access to /verify-otp only if the user is logged in but hasn't verified 2FA yet
return isLoggedIn && !isTwoFactorVerified;
} else if (isLoggedIn) {
// If logged in user tries to access login/register page, redirect appropriately
if (nextUrl.pathname === '/login' || nextUrl.pathname === '/register') {
if(!isTwoFactorVerified) {
// If trying to access login/register while needing OTP verification, redirect to verify page
// console.log(""Middleware: Logged in user needing 2FA tried accessing auth page. Redirecting to /verify-otp."");
return Response.redirect(new URL('/verify-otp', nextUrl.origin));
}
// Otherwise (logged in and verified), redirect to dashboard
// console.log(""Middleware: Logged in user tried accessing auth page. Redirecting to /dashboard."");
return Response.redirect(new URL('/dashboard', nextUrl.origin));
}
}
// Allow access to all other pages (like login, register, home) by default
return true;
},
// JWT Callback: Called whenever a JWT is created or updated.
async jwt({ token, user, trigger, session: updateSessionData }) {
// Initial sign in
if (user) {
token.id = user.id;
// IMPORTANT: Initially mark 2FA as NOT verified upon login,
// even if the user has it enabled. Verification happens per-session.
token.twoFactorVerified = false;
}
// If the session is updated (e.g., after successful OTP verification via unstable_update)
if (trigger === ""update"" && updateSessionData?.twoFactorVerified) {
token.twoFactorVerified = true;
// console.log(""JWT Updated: 2FA verified status set to true."");
}
return token;
},
// Session Callback: Called whenever a session is checked.
async session({ session, token }: { session: Session, token?: JWT }) {
// Ensure the user ID and 2FA status from the token are added to the session object
if (token?.id && session.user) {
session.user.id = token.id;
}
if (token?.twoFactorVerified !== undefined) {
session.twoFactorVerified = token.twoFactorVerified;
} else {
// Default to false if not present in token (shouldn't happen with jwt callback setup)
session.twoFactorVerified = false;
}
// console.log(`Session Callback: User ID: ${session.user?.id}, 2FA Verified: ${session.twoFactorVerified}`);
return session; // Return the augmented session object
}
},
providers: [], // Add providers in auth.ts, keep empty here for middleware compatibility
session: { strategy: 'jwt' }, // Use JWT strategy for session management
} satisfies NextAuthConfig;
auth.ts
(Main Configuration):
This file includes providers and the main logic.
// src/lib/auth.ts
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import bcrypt from 'bcrypt';
import { z } from 'zod';
import { prisma } from '@/lib/prisma';
import { authConfig } from './auth.config'; // Import base config
import { sendSms } from './plivo';
import { generateOtp, verifyOtp } from './utils'; // OTP utilities defined in Sec 2.5
import { User } from '@prisma/client'; // Import User type
// Module augmentation for Session/JWT types (already shown above)
// --- Add the declare module blocks here if not in a separate file ---
// Schema for the initial login form (Email/Password only)
const LoginSchema = z.object({
email: z.string().email({ message: 'Invalid email format.' }),
password: z.string().min(1, { message: 'Password is required.' }), // Min 1, actual length check is via bcrypt
});
export const {
handlers: { GET, POST },
auth,
signIn,
signOut,
unstable_update // Use unstable_update for modifying session state
} = NextAuth({
...authConfig, // Spread the base config (pages, session strategy, basic callbacks)
adapter: PrismaAdapter(prisma),
providers: [
Credentials({
async authorize(credentials) {
try {
const parsedCredentials = LoginSchema.safeParse(credentials);
if (!parsedCredentials.success) {
console.warn(""Credential parsing failed:"", parsedCredentials.error.flatten());
return null; // Invalid input format
}
const { email, password } = parsedCredentials.data;
// console.log(`Attempting login for email: ${email}`);
const user = await prisma.user.findUnique({ where: { email } });
if (!user || !user.password) {
// console.log(""User not found or password not set."");
return null; // User not found or doesn't use password auth
}
const passwordsMatch = await bcrypt.compare(password, user.password);
if (!passwordsMatch) {
// console.log(""Password mismatch for user:"", email);
return null; // Incorrect password
}
// console.log(`User ${email} authenticated successfully (primary).`);
// --- 2FA Check ---
if (user.twoFactorEnabled && user.phoneNumber) {
// console.log(`2FA enabled for user ${email}. Generating OTP.`);
const otp = generateOtp(); // Generate OTP (defined in Sec 2.5)
const otpExpiry = new Date(Date.now() + (parseInt(process.env.OTP_EXPIRY_MINUTES || '5') * 60 * 1000));
const otpHash = await bcrypt.hash(otp, 10); // Hash the OTP before storing
// Store hashed OTP and expiry in DB
await prisma.user.update({
where: { id: user.id },
data: {
twoFactorSecret: otpHash,
twoFactorExpiry: otpExpiry,
},
});
// console.log(`Stored OTP hash for user ${user.id}. Expiry: ${otpExpiry}`);
// Send OTP via Plivo
try {
await sendSms(user.phoneNumber, `Your verification code is: ${otp}. It expires in ${process.env.OTP_EXPIRY_MINUTES || 5} minutes.`);
// console.log(`OTP SMS potentially sent to ${user.phoneNumber}`); // Use 'potentially' due to simulation possibility
} catch (smsError: any) {
console.error(`Failed to send OTP SMS to ${user.phoneNumber}:`, smsError.message || smsError);
// Throw a specific error that can be caught by the authenticate action
// This will result in a CallbackRouteError in NextAuth
throw new Error(""Failed to send verification code. Please try again later."");
}
// IMPORTANT: Return the user object here.
// The JWT callback will set `twoFactorVerified = false` initially.
// The `authorized` callback in middleware will then force the redirect to /verify-otp.
// console.log(""Returning user object to signal pending 2FA verification."");
return user;
} else {
// 2FA not enabled, proceed with normal login
// console.log(`2FA not enabled for user ${email}. Proceeding with login.`);
// Clear any potentially stale OTP data
if(user.twoFactorSecret || user.twoFactorExpiry) {
await prisma.user.update({
where: { id: user.id },
data: { twoFactorSecret: null, twoFactorExpiry: null },
});
}
// The JWT callback will run, but the middleware won't redirect to /verify-otp
// because the user object doesn't require it (or 2FA is disabled).
// The session callback will set twoFactorVerified based on the token (which defaults based on this path).
return user; // Return full user object
}
} catch (error) {
// Catch errors from within authorize (e.g., the SMS sending error)
console.error(""Error during authorization:"", error);
// Re-throw the error if it's one we want to surface (like the SMS failure)
if (error instanceof Error && error.message.startsWith(""Failed to send"")) {
throw error; // Ensure this specific error propagates to AuthError
}
// For other errors (Zod, DB issues during OTP storage), return null to indicate failure
return null;
}
},
}),
// ... other providers like Google, GitHub, etc. if needed
],
// Callbacks are already defined in authConfig and spread above
});
Key Points in auth.ts
:
- Uses
PrismaAdapter
to sync NextAuth state with the database. - Implements the
Credentials
provider with email/password validation (LoginSchema
). - Verifies the password using
bcrypt.compare
. - 2FA Logic:
- Checks
user.twoFactorEnabled
. - If enabled: generates OTP, hashes it, stores hash/expiry, sends SMS via
sendSms
. - Crucially, returns the
user
object. The JWT/Session/authorized callbacks handle the subsequent flow (settingtwoFactorVerified: false
and redirecting). - Handles errors during SMS sending by throwing an error that
signIn
catches asCallbackRouteError
.
- Checks
- If 2FA is not enabled, it clears any old OTP data and returns the user object, allowing login to proceed normally.
- Module augmentation is used to define custom session/JWT properties (
twoFactorVerified
).
2.4 Configure Middleware:
Create the middleware file to protect routes based on the authorized
callback in auth.config.ts
.
// src/middleware.ts
import NextAuth from 'next-auth';
import { authConfig } from '@/lib/auth.config'; // Use the specific config for middleware
// Initialize NextAuth with the specific config for middleware checks
const { auth: middleware } = NextAuth(authConfig);
export default middleware; // Export the auth handler
// Matcher configuration: Apply middleware to relevant paths
export const config = {
// Apply middleware logic to these paths.
// The `authorized` callback determines access/redirects.
matcher: [
'/dashboard/:path*', // Protect all dashboard routes
'/verify-otp', // Manage access to the OTP verification page
'/login', // Apply logic to redirect logged-in users
'/register', // Apply logic to redirect logged-in users
// Exclude API routes, Next.js static files, and image optimization files
// Ensure this doesn't accidentally exclude your auth pages if they aren't listed above
'/((?!api|_next/static|_next/image|favicon.ico).*)',
],
};
// Explanation of Matcher:
// - Explicitly lists key routes (/dashboard*, /verify-otp, /login, /register) where auth logic is important.
// - Uses a negative lookahead `(?!...)` to exclude common static/API paths.
// - The final broad matcher `.*` ensures the middleware runs on other pages not explicitly excluded,
// allowing the `authorized` callback to handle redirects (e.g., logged-in user visiting `/`).
// Adjust the matcher based on your specific public/private route structure if needed.
2.5 Implement OTP Utilities:
Create utility functions for generating and verifying OTPs.
// src/lib/utils.ts
import otpGenerator from 'otp-generator';
import bcrypt from 'bcrypt';
import { prisma } from './prisma';
/**
* Generates a secure numeric OTP.
* @param length The desired length of the OTP (default: 6).
* @returns A string containing the generated OTP.
*/
export function generateOtp(length: number = 6): string {
return otpGenerator.generate(length, {
upperCaseAlphabets: false,
lowerCaseAlphabets: false,
specialChars: false,
digits: true,
});
}
/**
* Verifies a submitted OTP against the stored hash for a user.
* Also clears the OTP data on successful verification or if expired.
* @param userId The ID of the user attempting verification.
* @param submittedOtp The OTP code entered by the user.
* @returns Promise resolving to true if OTP is valid and not expired, false otherwise.
*/
export async function verifyOtp(userId: string, submittedOtp: string): Promise<boolean> {
try {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { twoFactorSecret: true, twoFactorExpiry: true },
});
if (!user || !user.twoFactorSecret || !user.twoFactorExpiry) {
// console.log(`Verification failed: No OTP data found for user ${userId}.`);
return false; // No OTP data stored
}
// Check expiry first
const isExpired = new Date() > user.twoFactorExpiry;
if (isExpired) {
// console.log(`Verification failed: OTP expired for user ${userId}. Expiry: ${user.twoFactorExpiry}`);
// Clear the expired OTP data for hygiene
await prisma.user.update({
where: { id: userId },
data: { twoFactorSecret: null, twoFactorExpiry: null },
});
return false; // OTP expired
}
// Compare submitted OTP with the stored hash
const isOtpValid = await bcrypt.compare(submittedOtp, user.twoFactorSecret);
if (isOtpValid) {
// console.log(`Verification successful for user ${userId}.`);
// OTP is valid, clear it immediately after successful verification
await prisma.user.update({
where: { id: userId },
data: { twoFactorSecret: null, twoFactorExpiry: null },
});
// console.log(`Cleared OTP data for user ${userId} after successful verification.`);
return true; // OTP is valid
} else {
// console.log(`Verification failed: Submitted OTP does not match stored hash for user ${userId}.`);
// Do not clear the OTP here, allow user to retry within expiry window.
return false; // OTP is invalid
}
} catch (error) {
console.error(`Error during OTP verification for user ${userId}:`, error);
return false; // Return false on any unexpected error
}
}