DEV Community

Abdur Rakib Rony
Abdur Rakib Rony

Posted on

Implementing Two-Factor Authentication in Next.js 14 with NextAuth.js

Two-factor authentication (2FA) adds an essential layer of security to your web applications. In this guide, I'll walk you through implementing a complete 2FA solution in a Next.js 14 application using NextAuth.js and TOTP (Time-based One-Time Password).

Why Implement 2FA?
With data breaches becoming increasingly common, relying solely on passwords is no longer sufficient. 2FA adds an extra verification step, requiring users to provide a code from their mobile device in addition to their password. This significantly reduces the risk of unauthorized access, even if passwords are compromised.

Prerequisites

  1. A Next.js 14 application using the App Router
  2. NextAuth.js for authentication
  3. MongoDB or another database for user data storage
  4. Basic knowledge of React and Next.js

Overview of Our Implementation
We'll build a complete 2FA system that:

  • Allows users to enable 2FA from their account settings
  • Generates a QR code for users to scan with authenticator apps
  • Verifies the 2FA setup with a code from the user's authenticator app
  • Redirects users to a verification page during login if 2FA is enabled
  • Provides a way to disable 2FA when needed

Setting Up the Database
First, we need to update our user schema to store 2FA-related information:

// models/user.js
import mongoose from "mongoose";

const userSchema = new mongoose.Schema(
  {
    // Existing user fields...
    email: String,
    firstName: String,
    lastName: String,
    // ... other fields

    // 2FA fields
    twoFactorEnabled: {
      type: Boolean,
      default: false,
    },
    twoFactorSecret: String,
  },
  { timestamps: true }
);

const User = mongoose.models.users || mongoose.model("users", userSchema);

export default User;
Enter fullscreen mode Exit fullscreen mode

Installing Required Packages
We'll need a few packages to generate and verify TOTP codes:
npm install otplib qrcode

Creating Utility Functions for 2FA
Next, let's create utility functions to handle 2FA operations:

// utils/2fa.js
import { authenticator } from 'otplib';
import QRCode from 'qrcode';

// Generate a secret for a user
export const generateTwoFactorSecret = (email) => {
  const secret = authenticator.generateSecret();
  const serviceName = 'YourAppName';
  const otpAuthUrl = authenticator.keyuri(email, serviceName, secret);

  return { secret, otpAuthUrl };
};

// Generate QR code as data URL
export const generateQRCode = async (otpAuthUrl) => {
  try {
    const qrCodeDataUrl = await QRCode.toDataURL(otpAuthUrl);
    return qrCodeDataUrl;
  } catch (error) {
    console.error('Error generating QR code:', error);
    throw error;
  }
};

// Verify OTP code
export const verifyToken = (token, secret) => {
  try {
    return authenticator.verify({ token, secret });
  } catch (error) {
    console.error('Error verifying token:', error);
    return false;
  }
};
Enter fullscreen mode Exit fullscreen mode

Configuring NextAuth.js
The next step is to update our NextAuth.js configuration to handle 2FA:

// lib/auth.js
import CredentialsProvider from "next-auth/providers/credentials";
import GoogleProvider from "next-auth/providers/google";
import FacebookProvider from "next-auth/providers/facebook";
import User from "@/models/user";
import { connectToDB } from "./db";

export const authOptions = {
  secret: process.env.NEXTAUTH_SECRET,
  pages: {
    signIn: "/login",
    error: "/login",
    verify: "/verify-2fa",
  },
  session: {
    strategy: "jwt",
  },
  callbacks: {
    async signIn({ user, account }) {
      if (account.provider === "credentials") {
        const email = user.email;
        await connectToDB();

        const dbUser = await User.findOne({ email });

        // Check if 2FA is enabled
        if (dbUser && dbUser.twoFactorEnabled) {
          user.requiresTwoFactor = true;
          return true;
        }

        return true;
      }

      // Handle social logins similarly
      if (account.provider === "google" || account.provider === "facebook") {
        // Similar logic for social logins
        // ...
      }

      return true;
    },
    async jwt({ token, user, trigger, session }) {
      // When first creating the JWT
      if (user) {
        token._id = user._id;
        token.user_role = user.user_role;

        // Pass along the 2FA requirement
        if (user.requiresTwoFactor) {
          token.requiresTwoFactor = true;
        }
      }

      // Handle updates, including 2FA verification
      if (trigger === "update" && session?.twoFactorVerified) {
        token.requiresTwoFactor = false;
      }

      return token;
    },
    async session({ session, token }) {
      // Include user details in session
      if (token?._id) {
        session.user._id = token._id;
        session.user.user_role = token.user_role;
      }

      // Expose the 2FA requirement to the client
      if (token.requiresTwoFactor) {
        session.requiresTwoFactor = true;
      }

      return session;
    },
  },
  providers: [
    // Your providers configuration
    // ...
  ],
};
Enter fullscreen mode Exit fullscreen mode

Creating API Endpoints for 2FA Management
We need to create several API endpoints to handle 2FA operations:

2FA Setup Endpoint

// app/api/2fa/setup/route.js
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server";
import { authOptions } from "@/lib/auth";
import { connectToDB } from "@/lib/db";
import User from "@/models/user";
import { generateTwoFactorSecret, generateQRCode } from "@/utils/2fa";

export async function POST() {
  try {
    const session = await getServerSession(authOptions);

    if (!session) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    await connectToDB();

    const user = await User.findOne({ email: session.user.email });

    if (!user) {
      return NextResponse.json({ error: "User not found" }, { status: 404 });
    }

    const { secret, otpAuthUrl } = generateTwoFactorSecret(user.email);
    const qrCodeDataUrl = await generateQRCode(otpAuthUrl);

    // Store the secret temporarily
    user.twoFactorSecret = secret;
    await user.save();

    return NextResponse.json({
      success: true,
      qrCodeDataUrl,
    });
  } catch (error) {
    console.error("Error setting up 2FA:", error);
    return NextResponse.json(
      { error: "Failed to set up 2FA" },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

2FA Verification Endpoint

// app/api/2fa/verify/route.js
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server";
import { authOptions } from "@/lib/auth";
import { connectToDB } from "@/lib/db";
import User from "@/models/user";
import { verifyToken } from "@/utils/2fa";

export async function POST(req) {
  try {
    const session = await getServerSession(authOptions);

    if (!session) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const { token } = await req.json();

    if (!token) {
      return NextResponse.json({ error: "Token is required" }, { status: 400 });
    }

    await connectToDB();

    const user = await User.findOne({ email: session.user.email });

    if (!user || !user.twoFactorSecret) {
      return NextResponse.json(
        { error: "2FA setup not initiated" },
        { status: 400 }
      );
    }

    const isValid = verifyToken(token, user.twoFactorSecret);

    if (!isValid) {
      return NextResponse.json({ error: "Invalid token" }, { status: 400 });
    }

    // Enable 2FA for the user
    user.twoFactorEnabled = true;
    await user.save();

    return NextResponse.json({
      success: true,
      message: "Two-factor authentication enabled",
    });
  } catch (error) {
    console.error("Error verifying 2FA token:", error);
    return NextResponse.json(
      { error: "Failed to verify token" },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

2FA Login Verification Endpoint

// app/api/auth/verify-2fa/route.js
import { getServerSession } from "next-auth/next";
import { NextResponse } from "next/server";
import { authOptions } from "@/lib/auth";
import { connectToDB } from "@/lib/db";
import User from "@/models/user";
import { verifyToken } from "@/utils/2fa";

export async function POST(req) {
  try {
    const session = await getServerSession(authOptions);

    if (!session) {
      return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
    }

    const { token } = await req.json();

    if (!token) {
      return NextResponse.json({ error: "Token is required" }, { status: 400 });
    }

    await connectToDB();

    const user = await User.findOne({ email: session.user.email });

    if (!user || !user.twoFactorSecret) {
      return NextResponse.json(
        { error: "User not found or 2FA not set up" },
        { status: 404 }
      );
    }

    const isValid = verifyToken(token, user.twoFactorSecret);

    if (!isValid) {
      return NextResponse.json({ error: "Invalid token" }, { status: 400 });
    }

    return NextResponse.json({
      success: true,
      message: "Two-factor authentication verified",
    });
  } catch (error) {
    console.error("Error verifying 2FA:", error);
    return NextResponse.json(
      { error: "Failed to verify 2FA" },
      { status: 500 }
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Creating the UI Components
2FA Setup Component

// components/TwoFactorSetup.jsx
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";
import { useSession } from "next-auth/react";

const TwoFactorSetup = () => {
  const { data: session } = useSession();
  const [qrCode, setQrCode] = useState("");
  const [token, setToken] = useState("");
  const [loading, setLoading] = useState(false);
  const [setupMode, setSetupMode] = useState(false);
  const [isEnabled, setIsEnabled] = useState(false);
  const [isStatusLoading, setIsStatusLoading] = useState(true);
  const [disableMode, setDisableMode] = useState(false);

  useEffect(() => {
    // Fetch the current 2FA status when component mounts
    const checkTwoFactorStatus = async () => {
      try {
        setIsStatusLoading(true);
        const response = await fetch("/api/2fa/status");
        const data = await response.json();

        if (response.ok) {
          setIsEnabled(data.enabled);
        }
      } catch (error) {
        console.error("Error checking 2FA status:", error);
      } finally {
        setIsStatusLoading(false);
      }
    };

    if (session) {
      checkTwoFactorStatus();
    }
  }, [session]);

  const initiateTwoFactorSetup = async () => {
    try {
      setLoading(true);
      const response = await fetch("/api/2fa/setup", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || "Failed to set up 2FA");
      }

      setQrCode(data.qrCodeDataUrl);
      setSetupMode(true);
    } catch (error) {
      toast({
        variant: "destructive",
        title: "Error",
        description: error.message,
      });
    } finally {
      setLoading(false);
    }
  };

  const verifyAndEnable = async () => {
    // Handle verification and enabling 2FA
    // ...
  };

  // Rest of the component implementation
  // ...

  return (
    <div className="space-y-6">
      <div className="border p-6 rounded-md">
        <h3 className="text-lg font-medium mb-4">Two-Factor Authentication</h3>

        {isEnabled ? (
          // Show 2FA enabled UI
          // ...
        ) : !setupMode ? (
          // Show setup button
          // ...
        ) : (
          // Show QR code and verification form
          // ...
        )}
      </div>
    </div>
  );
};

export default TwoFactorSetup;
Enter fullscreen mode Exit fullscreen mode

2FA Verification Page

// app/verify-2fa/page.jsx
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { toast } from "@/components/ui/use-toast";

export default function VerifyTwoFactor() {
  const [token, setToken] = useState("");
  const [loading, setLoading] = useState(false);
  const [sessionChecked, setSessionChecked] = useState(false);
  const router = useRouter();
  const { data: session, status, update } = useSession();

  useEffect(() => {
    // Only run this check after session is loaded
    if (status !== "loading") {
      setSessionChecked(true);

      // If user is authenticated but doesn't need 2FA, redirect them away
      if (session && !session.requiresTwoFactor) {
        router.push("/");
      }
    }
  }, [session, status, router]);

  const handleVerification = async (e) => {
    e.preventDefault();

    if (!token || token.length !== 6) {
      toast({
        variant: "destructive",
        title: "Error",
        description: "Please enter a valid 6-digit code",
      });
      return;
    }

    try {
      setLoading(true);

      const response = await fetch("/api/auth/verify-2fa", {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ token }),
      });

      const data = await response.json();

      if (!response.ok) {
        throw new Error(data.error || "Failed to verify code");
      }

      // Update the session to mark 2FA as verified
      await update({
        twoFactorVerified: true,
      });

      toast({
        title: "Success",
        description: "Two-factor authentication verified",
      });

      // Redirect to the home page
      router.push("/account/profile");
    } catch (error) {
      toast({
        variant: "destructive",
        title: "Error",
        description: error.message,
      });
    } finally {
      setLoading(false);
    }
  };

  return (
    <div className="w-full sm:max-w-md mx-auto py-10 space-y-8 p-4 sm:p-6">
      <div className="text-center">
        <h1 className="text-2xl font-bold">Verify Your Identity</h1>
        <p className="text-sm text-gray-500 mt-2">
          Enter the 6-digit code from your authenticator app
        </p>
      </div>

      <form onSubmit={handleVerification} className="space-y-6">
        {/* Form implementation */}
      </form>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Updating Middleware for 2FA Flow
To ensure users are properly redirected to the 2FA verification page when needed, update your middleware:

// middleware.js
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";

export async function middleware(req) {
  const url = req.nextUrl.clone();

  try {
    const token = await getToken({
      req,
      secret: process.env.NEXTAUTH_SECRET,
    });

    // Define protected routes
    const protectedRoutes = ["/dashboard", "/account", "/checkout"];

    // Check if 2FA is required but not verified
    const requires2FA = token?.requiresTwoFactor === true;

    // Skip middleware for the verify-2fa page and its API
    if (url.pathname === "/verify-2fa" || url.pathname === "/api/auth/verify-2fa") {
      return NextResponse.next();
    }

    // If 2FA is required, redirect to verification page
    if (token && requires2FA && url.pathname !== "/verify-2fa") {
      return NextResponse.redirect(new URL("/verify-2fa", req.url));
    }

    // Handle other authentication and authorization logic
    // ...

    return NextResponse.next();
  } catch (error) {
    console.error("Middleware error:", error);
    return NextResponse.redirect(new URL("/login", req.url));
  }
}

export const config = {
  matcher: ["/((?!api|_next/static|_next/image|favicon.ico|public).*)"],
};
Enter fullscreen mode Exit fullscreen mode

Environment Variables
Make sure you have these environment variables set in your .env.local file:

NEXTAUTH_SECRET=your_next_auth_secret
NEXTAUTH_URL=http://localhost:3000
Enter fullscreen mode Exit fullscreen mode

Common Issues and Troubleshooting
Database Connection Issues
If you're having trouble saving 2FA information to the database, ensure your model is correctly defined and imported. Use debugging logs to verify the data is correctly saved.

Session Not Updating
If the session isn't properly updating after 2FA verification, check your client-side update logic. The useSession().update() method is the correct way to update the session client-side.

Redirection Problems
If users aren't being redirected to the 2FA verification page when needed, check your middleware logic and ensure the token.requiresTwoFactor flag is properly set during login.

Top comments (0)