Two-Factor Authentication (2FA) Implementation Guide in Next.js: Strengthening Security with Email Verification
Introduction
Up to this point, we’ve implemented account registration, login, and logout using email and password with Next.js and Auth.js. While we now have the minimum authentication features, to actually run a web service we need to anticipate various scenarios and implement additional functionality.
- Verify that the registered email address is correct (so we can use it for various notifications and announcements)
- Password reset (for users who forget their passwords)
- Issue one-time tokens and strengthen security with two-factor authentication
In this article, we’ll implement the last one: “issuing one-time tokens to strengthen security with two-factor authentication.”
About Two-Factor Authentication
Two-Factor Authentication (2FA) is an authentication method used to enhance security by combining two different factors to verify a user’s identity.
This makes it harder for attackers to gain unauthorized access using only a single credential (for example, a password).
The factors used in two-factor authentication are chosen from the following three categories:
-
Knowledge (Something you know)
Information that only you know
Examples: password, PIN code, answers to security questions -
Possession (Something you have)
Physical or digital items you possess
Examples: smartphone, hardware token, security key, authentication codes received via email or SMS -
Inherence (Something you are)
Your physical characteristics
Examples: fingerprint, face recognition, iris scan, voiceprint recognition
Benefits
- Reduces the risk of unauthorized access and password leaks.
- Strengthens security and helps protect sensitive information.
Points to Note
- Recovery procedures in case you lose your possession factor (such as a smartphone).
- Codes sent via SMS or email are not perfect; more advanced methods (like security keys) are safer.
- Because it can be introduced easily in daily use, it’s a very useful mechanism for protecting personal information and important accounts.
Goal for This Article
Here we’ll treat verification codes received by email—which are relatively easy to implement—as our two-factor authentication method.
After entering an email address and password at login, the user will be able to log in by entering the one-time token sent to the corresponding email address.
Flow of Two-Factor Authentication
Here is an overview of the entire flow we’ll implement.
It’s broadly divided into two steps.
First, we send a one-time token via email.
The trigger is when the user enters their email address and password.
In this implementation, we’ll allow control over whether to use two-factor authentication on a per-user basis,
so the token issuance process will only run when a user who uses two-factor authentication logs in.
By modifying the login screen so that a token can be entered, the user can input the one-time token received by email and complete the login.
Implementation Steps
- Modify schema definitions (storage for one-time tokens, per-user 2FA settings, etc.)
- Implement token generation and token retrieval
- Implement email sending with the token set
- Modify login processing (token issuance and email sending)
- Modify the login screen (allow token input)
- Modify login processing (token verification)
Since there’s a lot to implement this time, we’ll insert some verification steps along the way.
1. Modify Schema Definitions
We’ll add to /prisma/schema.prisma.
Add to the User model:
isTwoFactorEnabled: Whether the user uses two-factor authentication (default is false; only users who want to use 2FA will switch this to true)
twoFactorConfirmation: Table for confirmed two-factor authentication
model User {
+ isTwoFactorEnabled Boolean @default(false)
+ twoFactorConfirmation TwoFactorConfirmation?
}
Add two new models.
TwoFactorToken: For managing tokens
TwoFactorConfirmation: Table for confirmed two-factor authentication
model TwoFactorToken {
id String @id @default(cuid())
email String
token String @unique
expires DateTime
@@unique([email, token])
}
model TwoFactorConfirmation {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId])
}
Once you’ve finished modifying the definitions, regenerate the types and reset the database.
npx prisma generate
npx prisma migrate reset
npx prisma db push
Since we reset the database, create a new user again from /auth/register.
This is the same as before, so there’s nothing special to consider here.
Check the settings for the user you created.
Start Prisma Studio and confirm that isTwoFactorEnabled for the created user is set to false.
npx prisma studio
2. Token Generation and Retrieval
For token retrieval, we’ll prepare two functions so we can fetch by ID and by email address.
Create a new /data/two-factor-token.ts file and add the following code:
import db from '@/lib/db'
export const getTwoFactorTokenByToken = async (token: string) => {
try {
const twoFactorToken = await db.twoFactorToken.findUnique({
where: { token },
})
return twoFactorToken
} catch {
return null
}
}
export const getTwoFactorTokenByEmail = async (email: string) => {
try {
const twoFactorToken = await db.twoFactorToken.findFirst({
where: { email },
})
return twoFactorToken
} catch {
return null
}
}
For token generation, we’ll extend an existing file.
/lib/tokens.ts
+ import crypto from 'crypto'
import { v4 as uuidv4 } from 'uuid'
import { getPasswordResetTokenByEmail } from '@/data/password-reset-token'
+ import { getTwoFactorTokenByEmail } from '@/data/two-factor-token'
import { getVerificationTokenByEmail } from '@/data/verification-token'
import db from '@/lib/db'
+ export const generateTwoFactorToken = async (email: string) => {
+ const token = crypto.randomInt(100_000, 1_000_000).toString()
+ const expires = new Date(new Date().getTime() + 5 * 60 * 1000)
+ const existingToken = await getTwoFactorTokenByEmail(email)
+ if (existingToken) {
+ await db.twoFactorToken.delete({
+ where: {
+ id: existingToken.id,
+ },
+ })
+ }
+ const twoFactorToken = await db.twoFactorToken.create({
+ data: {
+ email,
+ token,
+ expires,
+ },
+ })
+ return twoFactorToken
+ }
Here’s a brief explanation of the code:
const token = crypto.randomInt(100_000, 1_000_000).toString()
Generates a random 6-digit number.
const expires = new Date(new Date().getTime() + 5 * 60 * 1000)
Sets the token’s expiration time.
Here, the token is valid for 5 minutes from the time it’s generated.
const existingToken = await getTwoFactorTokenByEmail(email);
if (existingToken) {
await db.twoFactorToken.delete({
where: {
id: existingToken.id,
}
});
}
If a one-time token has already been generated, delete it.
This ensures that only the latest one-time token is valid.
Here we use the getTwoFactorTokenByEmail function we just created.
const twoFactorToken = await db.twoFactorToken.create({
data: {
email,
token,
expires,
}
});
return twoFactorToken;
Finally, we store the token and its expiration time in the database.
After saving, we return the full information so it can be passed to the email sending process.
3. Email Sending with Token Set
Next, we’ll implement the email sending process to deliver the generated token to the user.
/lib/mail.ts
+ export const sendTwoFactorTokenEmail = async (email: string, token: string) => {
+ await sendgrid.send({
+ from: 'test-taro@shinagawa-web.com',
+ to: email,
+ subject: 'Two-Factor Authentication Code',
+ html: `<p>Your two-factor authentication code is: ${token}</p>`,
+ })
+ }
This receives an email address and token, then sends an email.
Now we can generate tokens and send them via email.
Next, we’ll integrate this code into the login process, which will act as the trigger.
4. Modify Login Processing (Token Issuance and Email Sending)
Open /actions/login.ts and add processing for users who are subject to two-factor authentication.
'use server'
import { AuthError } from 'next-auth'
import { z } from 'zod'
import { signIn } from '@/auth'
import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation'
import { getTwoFactorTokenByEmail } from '@/data/two-factor-token'
import { getUserByEmail } from '@/data/user'
import db from '@/lib/db'
- import { sendVerificationEmail } from '@/lib/mail'
+ import { sendTwoFactorTokenEmail, sendVerificationEmail } from '@/lib/mail'
- import { generateVerificationToken } from '@/lib/tokens'
+ import { generateTwoFactorToken, generateVerificationToken } from '@/lib/tokens'
import { DEFAULT_LOGIN_REDIRECT } from '@/route'
import { LoginSchema } from '@/schema'
export const login = async (values: z.infer<typeof LoginSchema>) => {
const validatedFields = LoginSchema.safeParse(values)
if (!validatedFields.success) {
return { error: 'Please correct the input.' }
}
const { email, password, code } = validatedFields.data
const existingUser = await getUserByEmail(email)
if (!existingUser || !existingUser.email || !existingUser.password) {
return { error: 'Email does not exist!' }
}
if (!existingUser.emailVerified) {
const verificationToken = await generateVerificationToken(
existingUser.email,
)
const sendResult = await sendVerificationEmail(
verificationToken.email,
verificationToken.token,
)
if (sendResult.success) {
return {
success:
'Your email address is not verified. We have resent the verification link to your email.',
}
} else {
return { error: 'Failed to send the verification email.' }
}
}
+ if (existingUser.isTwoFactorEnabled && existingUser.email) {
+ const twoFactorToken = await generateTwoFactorToken(existingUser.email)
+ await sendTwoFactorTokenEmail(twoFactorToken.email, twoFactorToken.token)
+ return { twoFactor: true }
+ }
try {
await signIn('credentials', {
email,
password,
redirectTo: DEFAULT_LOGIN_REDIRECT,
})
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return {
error: 'The email address or password is incorrect.',
}
default:
return { error: 'An error has occurred.' }
}
}
throw error
}
}
It’s a bit long, but this is the full login.ts.
We’ve added new logic before the login process.
if (existingUser.isTwoFactorEnabled && existingUser.email) {
const twoFactorToken = await generateTwoFactorToken(existingUser.email)
await sendTwoFactorTokenEmail(twoFactorToken.email, twoFactorToken.token)
return { twoFactor: true }
}
If the logging-in user has isTwoFactorEnabled: true, we generate a token and send it via email.
After these processes complete, we return twoFactor: true to the login screen.
This is needed so the login screen can display a field for entering the token.
At the initial stage, the login screen doesn’t know whether the user uses two-factor authentication.
Only after the login request is processed on the server can we determine this, and then the login screen receives the result and displays the token input field.
Verification
We haven’t implemented the entire flow yet, but first let’s check whether an email with a token is delivered when we perform a login.
The first thing to do is set isTwoFactorEnabled: true.
In a typical application, you’d provide a screen where the user can toggle true/false, but implementing that would take some time, so we’ll skip it and change the value directly in Prisma Studio.
After starting Prisma Studio, follow the video below to change the setting.
npx prisma studio
Once done, actually perform a login and confirm that an email is delivered.
If you receive an email like the one below, it’s working.
5. Modify the Login Screen
First, we’ll modify the Zod schema definition so that the token can be passed between server and client.
/schema/index.ts
export const LoginSchema = z.object({
email: z.string().email({
message: 'Please enter your email address.',
}),
password: z.string().min(1, {
message: 'Please enter your password.',
}),
+ code: z.optional(z.string()),
})
We’ve added code to LoginSchema.
Since there are cases where code is not needed, we’ve made it optional.
Next, we’ll display the token input field on the login screen when it receives twoFactor: true from the server.
/components/auth/login-form.tsx
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import Link from 'next/link'
import { useState, useTransition } from 'react'
import { useForm } from 'react-hook-form'
import { z } from 'zod'
import { login } from '@/actions/login'
import { LoginSchema } from '@/schema'
import { FormError } from '../form-error'
import { FormSuccess } from '../form-success'
import { Button } from '../ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '../ui/form'
import { Input } from '../ui/input'
import { CardWrapper } from './card-wrapper'
export const LoginForm = () => {
const [showTwoFactor, setShowTwoFactor] = useState(false)
const [error, setError] = useState<string | undefined>('')
const [success, setSuccess] = useState<string | undefined>('')
const [isPending, setTransition] = useTransition()
const form = useForm<z.infer<typeof LoginSchema>>({
resolver: zodResolver(LoginSchema),
defaultValues: {
email: '',
password: '',
},
})
const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
setError('')
setSuccess('')
setTransition(async () => {
try {
const response = await login(values)
setError(response?.error)
setSuccess(response?.success)
setShowTwoFactor(!!response?.twoFactor)
} catch (e) {
setError('An error has occurred.')
}
})
}
const InputCode = () => (
<FormField
control={form.control}
name="code"
render={({ field }) => (
<FormItem>
<FormLabel>Two Factor Code</FormLabel>
<FormControl>
<Input {...field} disabled={isPending} placeholder="123456" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)
const InputEmailAndPassword = () => (
<>
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email Address</FormLabel>
<FormControl>
<Input
{...field}
disabled={isPending}
placeholder="nextjs@example.com"
type="email"
autoComplete="off"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
{...field}
placeholder="******"
type="password"
disabled={isPending}
/>
</FormControl>
<FormMessage />
<Button
size="sm"
variant="link"
asChild
className="px-0 font-normal"
>
<Link href="/auth/reset">Click here if you forgot your password</Link>
</Button>
</FormItem>
)}
/>
</>
)
return (
<CardWrapper
headerLabel="Log in with your email address and password"
buttonLabel="Click here if you don’t have an account yet"
buttonHref="/auth/register"
showSocial
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
{showTwoFactor ? <InputCode /> : <InputEmailAndPassword />}
</div>
<FormError message={error} />
<FormSuccess message={success} />
<Button type="submit" className="w-full" disabled={isPending}>
{showTwoFactor ? 'Confirm' : 'Log In'}
</Button>
</form>
</Form>
</CardWrapper>
)
}
Since the changes span a wide area, we’ve included the full code.
Here’s an explanation of the changes:
const [showTwoFactor, setShowTwoFactor] = useState(false)
We use useState to manage whether to display the token input field.
const onSubmit = async (values: z.infer<typeof LoginSchema>) => {
setError('')
setSuccess('')
setTransition(async () => {
try {
const response = await login(values)
setError(response?.error)
setSuccess(response?.success)
+ setShowTwoFactor(!!response?.twoFactor)
} catch (e) {
setError('An error has occurred.')
}
})
}
When twoFactor is returned from the server, we set its value.
const InputCode = () => (
...
)
This component renders the field for entering the one-time token.
const InputEmailAndPassword = () => (
...
)
This component renders the fields for entering the email address and password.
This component already existed, but we’ve extracted it at this point.
{showTwoFactor ? <InputCode /> : <InputEmailAndPassword />}
We switch which component to display based on the showTwoFactor state.
{showTwoFactor ? 'Confirm' : 'Log In'}
We also change the button label based on the showTwoFactor state.
With this setup, we can display the one-time token input field only when needed.
Verification
Once you’ve implemented everything up to this point, test whether the one-time token input field is displayed.
When you log in again from the /auth/login screen, do you now see a screen where you can enter the token?
As in the previous verification step, check whether you receive the two-factor authentication code.
Once you’ve confirmed this, we can move on to the final implementation.
6. Modify Login Processing (Token Verification)
With the previous steps, the token entered on the login screen is now sent back to the server.
On the server side, we’ll receive it, verify the token, and if there are no issues, perform the login.
Before that, since we’ll be working with the two-factor confirmation table, implement the following code:
/data/two-factor-confirmation.ts
import db from '@/lib/db'
export const getTwoFactorConfirmationByUserId = async (userId: string) => {
try {
const twoFactorConfirmation = await db.twoFactorConfirmation.findUnique({
where: { userId },
})
return twoFactorConfirmation
} catch {
return null
}
}
This code checks whether the target user exists in the two-factor confirmation table using the user ID.
We’ll use this code to update login.ts.
+ import { getTwoFactorConfirmationByUserId } from '@/data/two-factor-confirmation'
+ import db from '@/lib/db'
(※some parts omitted)
+ const { email, password, code } = validatedFields.data
(※some parts omitted)
if (existingUser.isTwoFactorEnabled && existingUser.email) {
+ if (code) {
+ const twoFactorToken = await getTwoFactorTokenByEmail(existingUser.email)
+ if (!twoFactorToken) {
+ return { error: 'Token not found.' }
+ }
+ if (twoFactorToken.token !== code) {
+ return { error: 'Token not found.' }
+ }
+ const hasExpired = new Date(twoFactorToken.expires) < new Date()
+ if (hasExpired) {
+ return { error: 'The token has expired.' }
+ }
+ await db.twoFactorToken.delete({
+ where: { id: twoFactorToken.id },
+ })
+ const existingConfirmation = await getTwoFactorConfirmationByUserId(
+ existingUser.id,
+ )
+ if (existingConfirmation) {
+ await db.twoFactorConfirmation.delete({
+ where: { id: existingConfirmation.id },
+ })
+ }
+ await db.twoFactorConfirmation.create({
+ data: {
+ userId: existingUser.id,
+ },
+ })
+ } else {
const twoFactorToken = await generateTwoFactorToken(existingUser.email)
await sendTwoFactorTokenEmail(twoFactorToken.email, twoFactorToken.token)
return { twoFactor: true }
+ }
}
Here’s an explanation of the code:
const { email, password, code } = validatedFields.data
Since we modified LoginSchema, the token is now sent, so we extract it here.
if (code) {
We start processing when a token has been provided.
const twoFactorToken = await getTwoFactorTokenByEmail(existingUser.email)
if (!twoFactorToken) {
return { error: 'Token not found.' }
}
if (twoFactorToken.token !== code) {
return { error: 'Token not found.' }
}
const hasExpired = new Date(twoFactorToken.expires) < new Date()
if (hasExpired) {
return { error: 'The token has expired.' }
}
await db.twoFactorToken.delete({
where: { id: twoFactorToken.id },
})
We verify the token. If it doesn’t match the token stored in the database or if it’s expired, we return an error so it can be displayed on the login screen.
If the token passes verification, we delete the stored token from the database.
const existingConfirmation = await getTwoFactorConfirmationByUserId(
existingUser.id,
)
if (existingConfirmation) {
await db.twoFactorConfirmation.delete({
where: { id: existingConfirmation.id },
})
}
await db.twoFactorConfirmation.create({
data: {
userId: existingUser.id,
},
})
Next, we add the user ID to the two-factor confirmation table.
To account for the possibility that a record already exists, we first check the table and delete any existing record before inserting a new one.
Verification
All the necessary implementation is now complete, so let’s do a final verification.
After entering your email address and password at login, you can log in by entering the one-time token sent to the corresponding email address.
If you enter an arbitrary (incorrect) token, an error message will be displayed on the login screen.
Even if the token matches, if the validity period (5 minutes from generation) has passed, an error message will be displayed on the login screen.
Conclusion
In this article, under the theme of “issuing one-time tokens to strengthen security with two-factor authentication,” we introduced how to implement two-factor authentication in Next.js.
In addition to email, tokens delivered via SMS are also commonly implemented.
If there’s an opportunity, I’d like to introduce how to implement SMS-based tokens as well.
Questions about this article 📝
If you have any questions or feedback about the content, please feel free to contact us.Go to inquiry form
Related Articles
Robust Authorization Design for GraphQL and REST APIs: Best Practices for RBAC, ABAC, and OAuth 2.0
2024/05/13Chat App (with Image/PDF Sending and Video Call Features)
2024/07/15Practical Component Design Guide with React × Tailwind CSS × Emotion: The Optimal Approach to Design Systems, State Management, and Reusability
2024/11/22Management Dashboard Features (Graph Display, Data Import)
2024/06/02Let's Build an Article Posting API with NestJS ─ Basics of Introducing Prisma and Implementing CRUD
2025/04/12Improving the Reliability of a NestJS App ─ Logging, Error Handling, and Testing Strategy
2024/09/11Deepening DB Design with NestJS × Prisma ─ Models, Relations, and Operational Design
2024/09/12NestJS × React × Railway: Implementing a Blog UI and Deploying to Production
2024/10/25












