New product features | The latest in technology | The weekly debugging nightmares & more!
January 6, 2024
Take my knowledge and shove it up your brain...
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.
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
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 });
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.
We'll use Resend to send emails to our users.
They have a generous free plan with 100 emails/day (3000/month)
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.
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:
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.
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.
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.
How to add credential authentication using Auth.js & Next 14
Allow users to login using their email & password.
How to enable password reset using auth.js
Forgot your password? now worries, I got your bro.
How to add 2-factor authentication
Enhance your users' security using 2-factor authentication
How to use clerk to protect your route handlers
Clerk can also protect route handlers, Nice