Blogs

Auth.js Email 2FA Implementation Guide | Code Samples (2025)

Auth.js Email 2FA Implementation Guide | Code Samples (2025)

Auth.js Email 2FA Implementation Guide | Code Samples (2025)

Feb 21, 2025

Easy email two-factor authentication in Auth.js with credentials (user/pass) provider. Complete step by step guide with code samples and example github repo.

Motivation

While Auth.js handles 2FA for OAuth providers, you need to build it yourself when using username/password authentication. This involves secure code generation, email delivery, session management, and a verification UI.

This guide shows you how to implement email-based 2FA that's maintainable and secure.

Prerequisites

  • An Auth.js project with Credentials provider

  • A NotificationAPI account (or another email service)

  • Prisma or another database ORM

  • Basic understanding of Next.js/React

Implementation

For the 2FA code (token), we're going to randomly generate a 6-digit number and store it with an expiration in the database. This approach is not the most secure, but we're doing it this way to keep this blog as brief as possible.

The complete code is available in our example repository.

1. Set Up Your Database

First, let's update your user objects to add fields for the token and its expiry:

model User {
  id            String    @id @default(cuid())
  email         String    @unique
  password      String
  // ...

  // add:
  twoFactorCode String?   // Temporary 2FA code
  twoFactorExp  DateTime? // 2FA code expiration
}

2. Setup An Email Service

We need an Email or SMS service to send out the 2FA token to the user. In this example, we will be using NotificationAPI, which comes free with a ton of emails and SMS messages.

Install NotificationAPI in your codebase:

npm install @auth/core notificationapi-node-client
# or

In the NotificationAPI dashboard, configure a 2fa_token notification and include a {{code}} parameter somewhere in its template designs.

Then, update your environment variables:


3. Modify Auth.js to Support 2FA

There are a couple of things we are doing in our Auth back-end code:

  1. Have a send2FACode function to send the verification code

  2. Add code (the 2FA token) to the credentials along with username/password

  3. Modify our authorize function to handle the first sign-in (username+password) attempt:

    • When not given a token, generate, store and send the token, and respond with: 2FA_REQUIRED

  4. Modify the same function to handle the second sign-in attempt (username+password+2fa):

    • When given a token, match it with the database and sign the user in

That is all the back-end you need!

// auth.ts
import NextAuth, { AuthOptions } from "next-auth";
import { PrismaAdapter } from "@auth/prisma-adapter";
import { PrismaClient } from "@prisma/client";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import NotificationAPI from "notificationapi-node-server-sdk";

const prisma = new PrismaClient();

// Generate a random 6-digit code
function generateCode(): string {
  return Math.floor(100000 + Math.random() * 900000).toString();
}

// Function to send 2FA code through NotificationAPI
async function send2FACode(email: string, code: string) {
  NotificationAPI.init(
    process.env.NOTIFICATIONAPI_CLIENT_ID || "",
    process.env.NOTIFICATIONAPI_CLIENT_SECRET || ""
  );

  NotificationAPI.send({
    notificationId: "2fa_code",
    user: {
      id: email,
      email: email,
    },
    mergeTags: {
      code,
    },
  });
}

export const authOptions: AuthOptions = {
  adapter: PrismaAdapter(prisma),
  providers: [
    CredentialsProvider({
      name: "Credentials",
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" },
        code: { label: "2FA Code", type: "text" },
      },
      async authorize(credentials) {
        if (!credentials?.email || !credentials?.password) {
          return null;
        }

        const user = await prisma.user.findUnique({
          where: { email: credentials.email },
        });

        if (!user || !user.password) {
          return null;
        }

        const isPasswordValid = await bcrypt.compare(
          credentials.password,
          user.password
        );

        if (!isPasswordValid) {
          return null;
        }

        // If no 2FA code provided, generate and send one
        if (!credentials.code) {
          const code = generateCode();
          const expiration = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes

          await prisma.user.update({
            where: { email: credentials.email },
            data: {
              twoFactorCode: code,
              twoFactorExp: expiration,
            },
          });

          await send2FACode(user.email, code);
          throw new Error("2FA_REQUIRED");
        }

        // Verify 2FA code
        if (user.twoFactorCode !== credentials.code) {
          throw new Error("INVALID_2FA_CODE");
        }

        if (user.twoFactorExp && new Date() > user.twoFactorExp) {
          throw new Error("2FA_CODE_EXPIRED");
        }

        // Clear 2FA code after successful verification
        await prisma.user.update({
          where: { email: credentials.email },
          data: {
            twoFactorCode: null,
            twoFactorExp: null,
          },
        });

        return {
          id: user.id,
          email: user.email,
          name: user.name,
        };
      },
    }),
  ],
  session: {
    strategy: "jwt",
  },
  pages: {
    signIn: "/auth/signin",
    error: "/auth/signin", // Will handle 2FA errors here
  },
  secret: process.env.NEXTAUTH_SECRET,
};

4. Modify the Front-End

First, on the sign-in page, you want to modify the logic so that:

  1. After the initial sign-in attempt with username+password, if we are challenged with "2FA_REQUIRED", to hide the username/password fields and display the token input field

  2. After the token is submitted, remember to submit all of the username+password+2fa

That's really it. Pretty simple, right?

Below is a simplified view of the front-end:

"use client";

import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";

export default function SignIn() {
  const router = useRouter();
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  const [code, setCode] = useState("");
  const [error, setError] = useState("");
  const [is2FAMode, setIs2FAMode] = useState(false);

  // handle form submit
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    
    const result = await signIn("credentials", {
      email,
      password,
      ...(is2FAMode ? { code } : {}),
      redirect: false,
    });

    if (result?.error) {
      // server is challenging us to provider token:
      if (result.error === "2FA_REQUIRED" && !is2FAMode) {
        setIs2FAMode(true);
        setError("");
        return;
      } else {
        throw error;
      }
    } else {
      router.push("/");
      router.refresh();
    }
  };

  return (
    {is2FAMode ? "Show token input field" : "Show username/password field"}
  );
}

Quality of Life

You may have noticed that nowhere did we write email HTML/CSS. NotificationAPI gives us:

  • A visual editor to design the notification content to keep your code clean

  • SMS option out of the box without Twilio

  • Monitoring and analytics

Security Best Practices

  1. Rate Limiting: Limit 2FA attempts per IP and user

  2. Code Expiry: Keep codes valid for 5 minutes maximum

  3. Code Generation: Use a hashing algorithm to generate the codes

  4. Secure Storage: Hash codes before storing, just like passwords

  5. Recovery Methods: Consider having recovery methods if users lose access to their email/phone for 2FA

The complete code is available in our example repository.

Launch Notifications In Minutes.

NotificationAPI Software Inc.

#0200-170 Water St.,

St. John's, NL

Canada A1C 3B9


hello@notificationapi.com


Proud sponsors of:

  • TechNL.ca

  • Get Building

  • DataForge Hackathon

  • BadAdvice Podcast

GET OUR NEWSLETTER

© 2020 - 2025 NotificationAPI Software Inc.

Launch Notifications In Minutes.

NotificationAPI Software Inc.

#0200-170 Water St.,

St. John's, NL

Canada A1C 3B9


hello@notificationapi.com


Proud sponsors of:

  • TechNL.ca

  • Get Building

  • DataForge Hackathon

  • BadAdvice Podcast

GET OUR NEWSLETTER

© 2020 - 2025 NotificationAPI Software Inc.

Launch Notifications In Minutes.

NotificationAPI Software Inc.

#0200-170 Water St.,

St. John's, NL

Canada A1C 3B9


hello@notificationapi.com


Proud sponsors of:

  • TechNL.ca

  • Get Building

  • DataForge Hackathon

  • BadAdvice Podcast

Join Our Newsletter

© 2020 - 2025 NotificationAPI Software Inc.