code examples

Sent logo
Sent TeamMar 8, 2026 / code examples / Plivo

How to Implement SMS Two-Factor Authentication with Plivo, NextAuth.js & Next.js

Learn to build secure SMS 2FA authentication in Next.js 15 using Plivo API and NextAuth.js v5. Complete tutorial with code examples, OTP verification, database setup, and production deployment.

How to Implement SMS Two-Factor Authentication with Plivo, NextAuth.js & Next.js

Introduction

<!-- DEPTH: Introduction lacks security context and real-world threat examples (Priority: Medium) --> <!-- GAP: Missing comparison with other 2FA methods (TOTP, hardware keys) to justify SMS choice (Type: Substantive) --> <!-- GAP: No discussion of SMS 2FA security limitations (SIM swapping, SS7 attacks) (Type: Critical) -->

Implement SMS-based two-factor authentication (2FA) in your Next.js application using Plivo's SMS API and NextAuth.js for session management. This tutorial demonstrates how to build a secure authentication system where users verify their identity through SMS one-time passwords (OTP) after entering their login credentials.

Two-factor authentication significantly strengthens application security by requiring both knowledge-based credentials (email and password) and possession-based verification (mobile device with SMS access). This guide covers Next.js 15 project setup, Plivo SMS integration, NextAuth.js authentication flow customization, OTP generation and verification, user 2FA preference management, and production deployment strategies.

What You'll Build:

Create a production-ready Next.js authentication system with:

  • Email and password credential authentication
  • Optional SMS-based 2FA that users can enable
  • Secure OTP generation and verification flow
  • Protected routes requiring 2FA completion
  • User profile management for 2FA settings

Technologies & Requirements:

  • Next.js: React framework for full-stack web applications (App Router). Next.js 15 (released October 2024) requires Node.js 18.18.0 or higher and supports React 19 RC.
  • NextAuth.js (v5/beta): Authentication solution for Next.js, handling sessions, providers, and callbacks. NextAuth.js v5 (also known as Auth.js) requires Next.js 14.0 minimum and is currently in beta (as of January 2025). Official documentation available at authjs.dev.
  • Plivo: Communications platform API for sending SMS messages. Pay-as-you-go pricing starting at $0.0035 per message for US (verified January 2025).
  • Prisma: Next-generation ORM for database access (example uses PostgreSQL, adaptable to others). Prisma ORM 6.x is the latest version with PostgreSQL 9.6+ support (strongly recommend currently supported versions).
  • Node.js: JavaScript runtime environment. Minimum version 18.18.0 required for Next.js 15.
  • Tailwind CSS: Utility-first CSS framework (default with create-next-app).
  • (Optional) shadcn/ui: UI component library used in examples.

System Architecture:

mermaid
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:

<!-- GAP: Missing step-by-step instructions for obtaining Plivo credentials (Type: Substantive) -->
  • Node.js 18.18.0 or higher (LTS version recommended) and npm/pnpm/yarn. Verify with node --version.
  • A Plivo account with available credits.
  • A Plivo phone number capable of sending SMS messages (purchased through the Plivo console). SMS rates start at approximately $0.0035 per message for US destinations.
  • A database instance (e.g., PostgreSQL 9.6+, 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).

Verification Date: All technology versions and requirements verified as of January 2025.


How to Set Up Your Next.js Project with Plivo SMS Authentication

Initialize your Next.js 15 application and install the required dependencies for SMS-based two-factor authentication.

Step 1: Create Your Next.js Application

Open your terminal and create a new Next.js project with TypeScript and Tailwind CSS:

bash
npx create-next-app@latest next-plivo-2fa --typescript --tailwind --eslint --app --src-dir --import-alias "@/*"
cd next-plivo-2fa

This command scaffolds a Next.js application using the App Router architecture, TypeScript for type safety, Tailwind CSS for styling, and ESLint for code quality.

Step 2: Install Required Dependencies for SMS 2FA

<!-- DEPTH: Package installation section could benefit from version compatibility matrix (Priority: Low) -->

Install NextAuth.js, Prisma ORM, Plivo SDK, and authentication utility libraries:

bash
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

Package explanations:

  • next-auth@beta: Authentication library for Next.js. Install the beta version for Next.js 14+ and App Router compatibility. NextAuth.js v5 is rebranding as Auth.js (documentation at authjs.dev).
  • @auth/prisma-adapter: Connects NextAuth with Prisma database.
  • prisma: Database ORM for PostgreSQL, MySQL, or SQLite (Prisma 6.x supports PostgreSQL 9.6+).
  • plivo-node: Official Plivo Node.js SDK for SMS messaging.
  • bcrypt: Password and OTP hashing library for secure storage.
  • zod: Runtime type validation and schema validation.
  • otp-generator: Generate secure numeric one-time passwords.
  • @types/*: TypeScript type definitions for development.

Step 3: Initialize Prisma Database

<!-- GAP: Missing database setup instructions for cloud providers (Supabase, PlanetScale, Neon) (Type: Substantive) -->

Set up Prisma with PostgreSQL as your database provider:

bash
npx prisma init --datasource-provider postgresql

This command creates:

  • prisma/schema.prisma: Database schema definition file.
  • .env: Environment configuration file with DATABASE_URL.

Environment variable details:

<!-- EXPAND: Could add security best practices section for environment variables (Type: Enhancement) -->
  • DATABASE_URL: PostgreSQL connection string for your database. Update with your actual credentials and host information.
  • NEXTAUTH_SECRET: Random secret string for encrypting JWT tokens and session cookies. Critical for authentication security. Generate using openssl rand -base64 32 on Linux/macOS.
  • NEXTAUTH_URL: Your application's canonical URL. Required for NextAuth callback URLs and redirects.
  • PLIVO_AUTH_ID / PLIVO_AUTH_TOKEN: Plivo API credentials from your dashboard. Keep these secure and never commit to version control.
  • PLIVO_PHONE_NUMBER: Your Plivo SMS-enabled phone number in E.164 format (e.g., +12345678900).
  • OTP_EXPIRY_MINUTES: OTP validity duration in minutes (default: 5 minutes).

Step 4: Configure Environment Variables for Plivo

Open the .env file and add your database connection string and Plivo API credentials. Obtain Plivo credentials from your Plivo Console (Dashboard → API Keys & Credentials).

dotenv
# .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

Step 5: Define Database Schema for 2FA Authentication

<!-- DEPTH: Schema design lacks explanation of security considerations for storing phone numbers (Priority: Medium) --> <!-- GAP: Missing discussion of GDPR/privacy compliance for phone number storage (Type: Critical) -->

Update prisma/schema.prisma to include NextAuth models and custom fields for SMS two-factor authentication.

prisma
// 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])
}

Database schema key components:

  • Standard NextAuth models: Account, Session, User, and VerificationToken for authentication state management.
  • Credentials authentication: Added password field to User model for email/password login.
  • 2FA custom fields added to User model:
    • phoneNumber: Stores user's verified phone number for SMS OTP delivery (unique constraint).
    • twoFactorEnabled: Boolean flag indicating whether SMS 2FA is active for the user account.
    • twoFactorSecret: Stores hashed OTP temporarily during verification (never store plain text OTPs).
    • twoFactorExpiry: Timestamp for OTP expiration validation.
  • Session tracking: Added twoFactorVerified field to Session model to track 2FA completion status per login session.

Step 6: Run Database Migrations

Generate and apply your Prisma database schema:

bash
npx prisma migrate dev --name init

This command performs three operations:

  1. Generates SQL migration files from your schema.prisma definition.
  2. Applies the migration to create tables in your PostgreSQL database.
  3. Generates the Prisma Client with TypeScript types for type-safe database queries.

Step 7: Understand the Project Structure

Your Next.js application structure organizes authentication components as follows:

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 architecture separates authentication concerns using Next.js App Router route groups: (auth) for public authentication pages and (protected) for secured application routes.


How to Configure NextAuth.js with Plivo SMS Integration

Configure NextAuth.js v5 (Auth.js) to handle credential authentication and integrate Plivo for SMS-based OTP delivery.

Configure Prisma Client Singleton

Create a singleton Prisma client instance to prevent connection pool exhaustion in development:

typescript
// 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;
}

Set Up Plivo Client for SMS Delivery

<!-- DEPTH: Plivo client setup lacks error handling best practices and retry logic (Priority: High) --> <!-- GAP: Missing rate limiting considerations for SMS sending (Type: Substantive) --> <!-- EXPAND: Could add SMS template customization and internationalization (Type: Enhancement) -->

Configure the Plivo SMS client using your API credentials from environment variables:

typescript
// 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
  }
}

Configure NextAuth.js Session and JWT Types

NextAuth.js v5 requires two configuration files: auth.config.ts for middleware and auth.ts for the main authentication logic.

First, extend the Session and JWT types using TypeScript module augmentation to add custom 2FA properties:

typescript
// 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
  }
}

Create NextAuth Configuration for Middleware (auth.config.ts)

<!-- DEPTH: Middleware configuration lacks explanation of edge cases and redirect loops (Priority: Medium) -->

This configuration file handles route protection and session checks in Next.js middleware:

typescript
// 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;

Create Main NextAuth Configuration (auth.ts)

<!-- DEPTH: Authentication flow lacks comprehensive error scenarios and user feedback mechanisms (Priority: High) --> <!-- GAP: Missing brute force protection and rate limiting for OTP attempts (Type: Critical) --> <!-- GAP: No discussion of backup codes or account recovery if phone is lost (Type: Critical) -->

Implement the Credentials provider with Plivo SMS integration for OTP delivery:

typescript
// 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 authentication flow elements:

  • Uses PrismaAdapter to synchronize NextAuth session state with your PostgreSQL database.
  • Implements Credentials provider with Zod schema validation for email and password.
  • Verifies passwords securely using bcrypt comparison.
  • SMS 2FA workflow:
    • Checks if user has twoFactorEnabled flag set.
    • Generates secure numeric OTP and hashes it with bcrypt before database storage.
    • Sends OTP via Plivo SMS API to user's registered phone number.
    • Returns user object with twoFactorVerified: false to trigger middleware redirect to verification page.
    • Handles SMS delivery errors gracefully with user-friendly error messages.
  • For users without 2FA enabled, clears any stale OTP data and proceeds with standard login.
  • Uses TypeScript module augmentation for type-safe custom session properties.

Configure Next.js Middleware for Route Protection

Create middleware to protect authenticated routes based on 2FA verification status:

typescript
// 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.

Implement OTP Generation and Verification Utilities

<!-- DEPTH: OTP utilities lack discussion of cryptographic security considerations (Priority: High) --> <!-- GAP: Missing rate limiting for OTP verification attempts (Type: Critical) --> <!-- EXPAND: Could add audit logging for security events (Type: Enhancement) -->

Create utility functions for secure OTP generation and validation against stored hashes:

typescript
// 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
  }
}
<!-- GAP: Document is incomplete - missing sections on UI implementation, testing, and deployment (Type: Critical) --> <!-- GAP: Missing troubleshooting section for common issues (Type: Substantive) --> <!-- GAP: No section on monitoring and observability for 2FA system (Type: Substantive) --> <!-- EXPAND: Could add section on migrating existing users to 2FA (Type: Enhancement) --> <!-- EXPAND: Could add performance optimization tips for high-volume SMS sending (Type: Enhancement) -->