Nader's Daily Blog
Welcome to Every Developers favorite blog in the Devosphere

New product features | The latest in technology | The weekly debugging nightmares & more!

How to add email verification using Auth.js

How to add email verification using Auth.js

January 6, 2024

Nader Elmahdy

Nader Elmahdy

Take my knowledge and shove it up your brain...

Enhance your website security by allowing only verified users to log in.

Authentication

In the last article, we learned how to implement credential authentication using auth.js, but users could create an account using any email even if it doesn't belong to them.

So today, we'll learn how to enable email verification where the user will get an email with a verification link, and he can't log in until he's verified.

Update the user model

We'll need to add a new field in our use model and add a new emailVerified field, it will be either a null or the verification time.

schema.prisma

1model User {
2  id                    String                 @id @default(cuid())
3  name                  String?
4  email                 String?                @unique
5  emailVerified         DateTime?
6  password              String?
7  accounts              Account[]
8

We'll also add a new model for the verification token

schema.prisma

1model VerificationToken {
2  id      String   @id @default(cuid())
3  email   String
4  token   String   @unique
5  expires DateTime
6
7  @@unique([email, token])
8}

don't forget to shut your app and run npx prisma generate

then npx prisma db push



Disable log-in after signing up

We made it so that users are logged in and redirected to the users page after they sign up, we'll have to disable that because now users will get a verification email after they sign up.

Go to actions/signup.ts and remove this code block:

signup.ts

1  await signIn("credentials", {
2    email,
3    password,
4    redirectTo: DEFAULT_LOGIN_REDIRECT,
5  });


Creating a verification token

The idea behind email verification is that a token would be generated right after the user signs up, this token expires after a certain amount of time (eg: 15 mins).

The token is linked to that user in the database.

The user will receive an email with a link, this link contains the token as a query (eg: /verify?token=1545313).

We will read that token, and verify that it exists and isn't expired, if so update the user row and mark his email as verified, then remove that token from the database.

Go to the lib folder and create a tokens.ts file.

the token will be generated using uuid, so go ahead and install it.

npm i uuid @types/uuid

lib/tokens.ts

1import { prisma } from "./prisma";
2
3import { v4 as uuidv4 } from "uuid";
4
5export const generateVerificationToken = async (email: string) => {
6  const token = uuidv4();
7  const expires = new Date(new Date().getTime() + 3600 * 1000);
8
9  const existingToken = await prisma.verificationToken.findFirst({
10    where: { email },
11  });
12
13  if (existingToken) {
14    await prisma.verificationToken.delete({
15      where: {
16        id: existingToken.id,
17      },
18    });
19  }
20
21  const verficationToken = await prisma.verificationToken.create({
22    data: {
23      email,
24      token,
25      expires,
26    },
27  });
28
29  return verficationToken;
30};
31

This function checks if there's an existing token linked to that user (eg: the user didn't receive the email or forgot to verify so he requested another one).

If there is, delete the old token and generate a new one.

Sending emails

We'll use Resend to send emails to our users.

They have a generous free plan with 100 emails/day (3000/month)

Note: you can only send emails to yourself (the email used to create a resend account) if you want to send emails to anyone you'll need to buy a domain and add their DNS records.

Install resend using npm i resend

create a mail.ts file in the lib folder.

mail.ts

1import { Resend } from "resend";
2
3const resend = new Resend(process.env.RESEND_API_KEY);
4
5const domain = process.env.NEXT_PUBLIC_APP_URL;
6export const sendVerificationEmail = async (email: string, token: string) => {
7  const confirmLink = `${domain}/verify-email?token=${token}`;
8
9  await resend.emails.send({
10    from: process.env.FROM_EMAIL as string,
11    to: email,
12    subject: "Confirm your email",
13    html: `<p>Click <a href="${confirmLink}">here</a> to confirm email.</p>`,
14  });
15};
16

You can add these variables to your .env file


RESEND_API_KEY=*your key*
NEXT_PUBLIC_APP_URL=http://localhost:3000
FROM_EMAIL=onboarding@resend.dev

You'll need to change the app url variable when you have a domain, for now, it's localhost:3000

The from email is the sender's email, you have to use this email for now.

When you buy a domain and connect it to resend, you can use an email with that domain.

You can get the resend API key by visiting API Keys ยท Resend and generate a new key.

In your utils folder create a verification-token.ts file, it will have 2 functions that will help us get the token info.

verification-token.ts

1import { prisma } from "@/lib/prisma";
2
3export const getVerificationTokenByToken = async (token: string) => {
4  try {
5    const verificationToken = await prisma.verificationToken.findUnique({
6      where: { token },
7    });
8
9    return verificationToken;
10  } catch {
11    return null;
12  }
13};
14
15export const getVerificationTokenByEmail = async (email: string) => {
16  try {
17    const verificationToken = await prisma.verificationToken.findFirst({
18      where: { email },
19    });
20
21    return verificationToken;
22  } catch {
23    return null;
24  }
25};
26

Now we have the functions we need, all we have to do is go back to actions/signup.ts and send an email after a user signs up.

actions/signup.ts

1"use server";
2
3import * as z from "zod";
4import bcrypt from "bcryptjs";
5
6import { prisma } from "@/lib/prisma";
7import { RegisterSchema } from "@/schemas";
8import { getUserByEmail } from "@/utils/users";
9import { sendVerificationEmail } from "@/lib/mail";
10import { generateVerificationToken } from "@/lib/tokens";
11
12export const signup = async (values: z.infer<typeof RegisterSchema>) => {
13  const validatedFields = RegisterSchema.safeParse(values);
14
15  if (!validatedFields.success) {
16    return { error: "Invalid fields!" };
17  }
18
19  const { email, password, name } = validatedFields.data;
20  const hashedPassword = await bcrypt.hash(password, 10);
21
22  const existingUser = await getUserByEmail(email);
23
24  // Prevent sign up if email already exists and has been verified
25  if (existingUser?.emailVerified) {
26    return { error: "Email already in use!" };
27  }
28
29  await prisma.user.create({
30    data: {
31      name,
32      email,
33      password: hashedPassword,
34    },
35  });
36
37  const verificationToken = await generateVerificationToken(email);
38  await sendVerificationEmail(verificationToken.email, verificationToken.token);
39
40  return { success: "Confirmation Email sent!" };
41};
42

And now we're ready to test.

Delete your current email from the database, you can do that by running npx prisma studio in your terminal, open http://localhost:5000 and delete the row with your email.

Let's check my inbox to see if it works.

And I did receive the email, very good.

If you click the link you received, you'll get a 404 error because we haven't created the verify-email page yet so let's do that.

Verifying the email

Go to app/(auth) and create verify-email/page.tsx

Now go to routes.ts file and add "/verify-email" to the publicRoutes array.

Go to your components folder and create VerificationForm.tsx

The verification form component will:

  1. Get the token from the URL query
  2. Verify that the token exists in the database and that it's not expired
  3. Verify the email connected to that token.
  4. After the verification, redirect the user to the login page.

VerificationForm.tsx

1"use client";
2
3import { Card, Spinner } from "@nextui-org/react";
4import { useCallback, useEffect, useState } from "react";
5import { useRouter, useSearchParams } from "next/navigation";
6
7import { verifyEmail } from "@/actions/verify-email";
8
9import { FaCircleCheck, FaCircleExclamation } from "react-icons/fa6";
10
11type VerificationFormProps = {};
12
13export const VerificationForm = ({}: VerificationFormProps) => {
14  const [error, setError] = useState<string | undefined>();
15  const [success, setSuccess] = useState<string | undefined>();
16
17  const router = useRouter();
18  const searchParams = useSearchParams();
19
20  const token = searchParams.get("token");
21
22  const onSubmit = useCallback(() => {
23    if (success || error) return;
24
25    if (!token) {
26      setError("Missing token!");
27      return;
28    }
29
30    verifyEmail(token)
31      .then((data) => {
32        if (data.success) {
33          setSuccess(data.success);
34          router.push("/login");
35        }
36        setError(data.error);
37      })
38      .catch(() => {
39        setError("Something went wrong!");
40      });
41  }, [token, success, error]);
42
43  useEffect(() => {
44    onSubmit();
45  }, [onSubmit]);
46
47  return (
48    <Card className="w-96">
49      {!success && !error && (
50        <>
51          <h1 className="text-lg font-bold">Verifying</h1>
52          <Spinner />
53        </>
54      )}
55      {success && (
56        <div className="bg-green-400/30 p-2 flex items-center w-full rounded-lg">
57          <FaCircleCheck size={20} className="text-green-600" />
58          <p className=" text-sm ml-1 text-green-700">{success}</p>
59        </div>
60      )}
61      {error && (
62        <div className="bg-red-400/30 p-2 flex items-center w-full rounded-lg">
63          <FaCircleExclamation size={20} className="text-red-600" />
64          <p className=" text-sm ml-1 text-red-700">{error}</p>
65        </div>
66      )}
67    </Card>
68  );
69};
70

Let's try that out!

Looks like it's working, let's check our database to see if the email has been verified or not.

And here we go the email has been verified.

Prevent non-verified users from logging in.

Congrats, we've implemented email verification to our website, but who said we prevented non-verified users from logging in in the first place?

I probably should've done that first... anyway let's do it.

Go to actions/login.ts

We'll modify this function to check if the email is verified before attempting to sign In.

If it's not verified, send a verification email.

actions/login.ts

1"use server";
2
3import * as z from "zod";
4import bcrypt from "bcryptjs";
5import { AuthError } from "next-auth";
6
7import { signIn } from "@/auth";
8import { LoginSchema } from "@/schemas";
9import { getUserByEmail } from "@/utils/users";
10import { DEFAULT_LOGIN_REDIRECT } from "@/routes";
11import { sendVerificationEmail } from "@/lib/mail";
12import { generateVerificationToken } from "@/lib/tokens";
13
14export const login = async (
15  values: z.infer<typeof LoginSchema>,
16  callbackUrl?: string | null
17) => {
18  const validatedFields = LoginSchema.safeParse(values);
19
20  if (!validatedFields.success) {
21    return { error: "Invalid fields!" };
22  }
23
24  const { email, password } = validatedFields.data;
25
26  const existingUser = await getUserByEmail(email);
27
28  if (!existingUser || !existingUser.email || !existingUser.password) {
29    return { error: "Email does not exist!" };
30  }
31  // Ceck if the password is correct
32  if (!(await bcrypt.compare(password, existingUser.password))) {
33    return { error: "Invalid credentials!" };
34  }
35
36  // check if the email is verified
37  if (!existingUser.emailVerified) {
38    const verificationToken = await generateVerificationToken(
39      existingUser.email
40    );
41
42    await sendVerificationEmail(
43      verificationToken.email,
44      verificationToken.token
45    );
46
47    return { success: "Confirmation email sent!" };
48  }
49
50  // Login if all checks pass
51  try {
52    await signIn("credentials", {
53      email,
54      password,
55      redirectTo: callbackUrl || DEFAULT_LOGIN_REDIRECT,
56    });
57
58    return { success: "Successfully logged in!" };
59  } catch (error) {
60    if (error instanceof AuthError) {
61      switch (error.type) {
62        case "CredentialsSignin":
63          return { error: "Invalid credentials!" };
64        default:
65          return { error: "Something went wrong!" };
66      }
67    }
68
69    throw error;
70  }
71};
72

Alright, we're ready to test things out, but before we do that, let's add an extra layer of security.

Go to auth.ts file

We'll check if the email is verified before we return a session.

auth.ts

1import NextAuth from "next-auth";
2import { PrismaAdapter } from "@auth/prisma-adapter";
3
4import { prisma } from "./lib/prisma";
5import authConfig from "@/auth.config";
6import { getUserById } from "@/utils/users";
7
8export const {
9  handlers: { GET, POST },
10  auth,
11  signIn,
12  signOut,
13  update,
14} = NextAuth({
15  pages: {
16    signIn: "/login",
17    error: "/error",
18  },
19  callbacks: {
20    async signIn({ user, account }) {
21      const existingUser = await getUserById(user.id);
22
23      // Prevent sign in without email verification
24      if (!existingUser?.emailVerified) return false;
25
26      return true;
27    },
28  },
29  adapter: PrismaAdapter(prisma),
30  session: { strategy: "jwt" },
31  ...authConfig,
32});
33

Now let's try to log in, if we did everything correctly, we should be redirected to the users page.

And it works as expected.

If you want to confirm that this is actually working, create a new account, and try to log in without verification.

Keep in mind that you won't receive a verification email unless it's the same email you used to create the resend account.

Next steps

In the next tutorial, we'll learn how to enable 2-factor authentication, so users won't log in unless they submit a code sent to their email.

You might also like: