Building a Secure OTP-based Login System in Next.js

Abdur Rakib Rony - Jun 28 - - Dev Community

In today's digital age, ensuring the security of user authentication is paramount. One effective method is using One-Time Passwords (OTPs) for login. In this post, we'll walk through how to implement an OTP-based login system using Next.js, with both email and phone number options.

Why Use OTP?
OTPs add an extra layer of security by requiring a temporary code sent to the user's email or phone number. This method reduces the risk of unauthorized access, as the code is valid for a short period.

Setting Up the Frontend
We start by creating a login component that captures the user's email or phone number and handles OTP sending and verification.

//login component
"use client";
import { useState, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Lock, Mail, Phone } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
  InputOTP,
  InputOTPGroup,
  InputOTPSlot,
} from "@/components/ui/input-otp";
import { SendOTP } from "@/utils/SendOTP";
import { useRouter } from "next/navigation";
import { signIn } from "next-auth/react";

const Login = () => {
  const [contact, setContact] = useState("");
  const [otp, setOtp] = useState(false);
  const [otpCode, setOtpCode] = useState("");
  const [receivedOtpCode, setReceivedOtpCode] = useState("");
  const [timeLeft, setTimeLeft] = useState(60);
  const [timerRunning, setTimerRunning] = useState(false);
  const [resendClicked, setResendClicked] = useState(false);
  const [hasPassword, setHasPassword] = useState(false);
  const [password, setPassword] = useState("");
  const [isIncorrectOTP, setIsIncorrectOTP] = useState(false);

  const router = useRouter();

  const handleSendOtp = async () => {
    setOtp(true);
    startTimer();
    setResendClicked(true);
    const data = await SendOTP(contact);
    if (data?.hasPassword) {
      setHasPassword(data?.hasPassword);
    }
    if (data?.otp) {
      setReceivedOtpCode(data?.otp);
    }
  };

  const handleLogin = async () => {
    if (otpCode === receivedOtpCode) {
      await signIn("credentials", {
        redirect: false,
        email: isNaN(contact) ? contact : contact + "@gmail.com",
      });
      router.push("/");
    } else {
      setIsIncorrectOTP(true);
    }
  };

  const startTimer = () => {
    setTimeLeft(60);
    setTimerRunning(true);
  };

  const resendOTP = () => {
    setTimerRunning(false);
    startTimer();
    setResendClicked(true);
    handleSendOtp();
  };

  useEffect(() => {
    let timer;
    if (timerRunning) {
      timer = setTimeout(() => {
        if (timeLeft > 0) {
          setTimeLeft((prevTime) => prevTime - 1);
        } else {
          setTimerRunning(false);
        }
      }, 1000);
    }

    return () => clearTimeout(timer);
  }, [timeLeft, timerRunning]);

  useEffect(() => {
    if (contact === "" || contact === null) {
      setOtp(false);
      setOtpCode("");
      setTimeLeft(60);
      setTimerRunning(false);
      setResendClicked(false);
    }
  }, [contact]);

  return (
    <div>
      <div className="relative w-full max-w-sm">
        {contact === "" || isNaN(contact) ? (
          <Mail
            className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
            size={20}
          />
        ) : (
          <Phone
            className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
            size={20}
          />
        )}
        <Input
          type="text"
          name="contact"
          value={contact}
          placeholder="Email or phone"
          onChange={(e) => setContact(e.target.value)}
          disabled={contact && otp}
          className="pl-10"
        />
      </div>
      {hasPassword ? (
        <div className="relative w-full max-w-sm mt-4">
          <Lock
            className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400"
            size={20}
          />
          <Input
            type="password"
            name="password"
            value={password}
            placeholder="Password"
            onChange={(e) => setPassword(e.target.value)}
            className="pl-10"
          />
        </div>
      ) : (
        <div>
          {contact && otp && (
            <div className="text-center text-green-500 text-base mt-1">
              OTP sent successfully. Please enter OTP below.
            </div>
          )}
          {contact && otp && (
            <div className="space-y-2 w-full flex flex-col items-center justify-center my-2">
              <InputOTP
                maxLength={4}
                value={otpCode}
                onChange={(value) => setOtpCode(value)}
                isError={isIncorrectOTP}
              >
                <InputOTPGroup>
                  <InputOTPSlot index={0} />
                  <InputOTPSlot index={1} />
                  <InputOTPSlot index={2} />
                  <InputOTPSlot index={3} />
                </InputOTPGroup>
              </InputOTP>
              <div>
                {resendClicked && timeLeft > 0 ? (
                  <p className="text-sm">
                    Resend OTP available in{" "}
                    <span className="text-blue-500">
                      {timeLeft > 0 ? `${timeLeft}` : ""}
                    </span>
                  </p>
                ) : (
                  <Button
                    variant="link"
                    onClick={resendOTP}
                    className="text-blue-500"
                  >
                    Resend OTP
                  </Button>
                )}
              </div>
            </div>
          )}
        </div>
      )}
      {receivedOtpCode ? (
        <Button
          onClick={handleLogin}
          className="w-full mt-4 bg-green-500 hover:bg-green-400"
        >
          Login
        </Button>
      ) : (
        <Button
          onClick={handleSendOtp}
          className="w-full mt-4 bg-green-500 hover:bg-green-400"
        >
          Next
        </Button>
      )}
      {isIncorrectOTP && (
        <p className="text-red-500 text-sm text-center mt-2">
          Incorrect OTP. Please try again.
        </p>
      )}
    </div>
  );
};

export default Login;

Enter fullscreen mode Exit fullscreen mode

This component manages the user interaction for entering their contact information, sending the OTP, and handling the login process. It includes state management for various aspects such as OTP verification, countdown timer, and error handling.

Backend API for OTP Generation and Sending
Next, we'll set up the backend to handle OTP generation and sending. The OTP can be sent via email or SMS based on the user's contact information.

//OTP Generation and Sending
import { sendVerificationSMS } from "@/lib/sendSMS";
import User from "@/models/user";
import { NextResponse } from "next/server";
import { connectToDB } from "@/lib/db";
import nodemailer from "nodemailer";

const generateOTP = () => {
  const digits = "0123456789";
  let OTP = "";

  for (let i = 0; i < 4; i++) {
    OTP += digits[Math.floor(Math.random() * 10)];
  }

  return OTP;
};

const sendVerificationEmail = async (contact, otp) => {
  try {
    let transporter = nodemailer.createTransport({
      service: "gmail",
      auth: {
        user: "your-email@gmail.com",
        pass: "your-email-password",
      },
    });

    let info = await transporter.sendMail({
      from: `"Your Company" <your-email@gmail.com>`,
      to: contact,
      subject: "Verification Code",
      text: `Your verification code is: ${otp}`,
    });
    return info.messageId;
  } catch (error) {
    console.error("Error sending email:", error);
    throw new Error("Error sending verification email");
  }
};

export async function POST(req, res) {
  try {
    await connectToDB();
    const otp = generateOTP();
    const { contact } = await req.json();

    const existingUser = await User.findOne({
      email: isNaN(contact) ? contact : contact + "@gmail.com",
    });

    if (isNaN(contact)) {
      await sendVerificationEmail(contact, otp);
      return NextResponse.json({
        message: "Verification code has been sent to your email",
        otp,
      });
    } else {
      await sendVerificationSMS(contact, otp);
      return NextResponse.json({
        message: "Verification code has been sent",
        otp,
      });
    }
  } catch (error) {
    console.error(error);
    return NextResponse.error(
      "An error occurred while processing the request."
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

This backend code handles OTP generation and sends it either via email or SMS depending on the user's input. The generateOTP function creates a random 4-digit OTP, and the sendVerificationEmail and sendVerificationSMS functions send the OTP to the user.

Conclusion
Implementing an OTP-based login system enhances the security of your application by adding an additional verification step. This system ensures that only users with access to the provided email or phone number can log in, protecting against unauthorized access.

Feel free to modify and expand upon this basic implementation to suit your specific requirements. Happy coding!

. . . .