How to Implement a User-Facing Password Reset Feature: From Token Verification to Setting a New Password
Introduction
Previously, we implemented the process of generating a token and sending a verification email. This time we’ll implement the rest of the flow. We’ll build the screen where users can enter a new password after clicking the link.
When the user enters a new password, the server will verify that the token is one that has been generated, and then we’ll implement the process that accepts the new password.
Goal for This Article
When you click the link in the email, the screen we create this time should be displayed. After entering a password and clicking the “Set new password” button, a message saying “Your password has been updated. Please log in from the login screen.” will be displayed.
Then confirm that you can log in with the new password from the login screen.
Token Verification
First, we’ll implement the process that receives the token and verifies it on the server side. Let’s summarize the flow.
- ① Check the database to see if the received token is valid, and if it does not exist, return an error
- ② Retrieve the token from
PasswordResetToken - ③ Check the token’s expiration time, and if it has expired, return an error
- ④ Once all checks are complete, update the user’s password with the newly entered password
Basically, token checks happen in two places: ① and ③.
Create the /actions/new-password.ts file and write the following code.
First, implement the Zod schema for the password that the user will enter.
Add the following code to the schema/index.ts file.
+ export const NewPasswordSchema = z
+ .object({
+ password: z.string().min(6, {
+ message: 'Password must be at least 6 characters',
+ }),
+ confirmPassword: z.string().min(6, {
+ message: 'Password must be at least 6 characters',
+ }),
+ })
+ .refine((data) => data.password === data.confirmPassword, {
+ message: 'Passwords do not match',
+ path: ['confirmPassword'],
+ })
For updating the password, the screen requires the user to enter it twice, so we define two fields: password and confirmPassword.
We also define a comparison so that an error is shown when they do not match.
Using the schema we just created, we’ll implement the server action.
Write the following code in actions/new-password.ts.
'use server'
import bcrypt from 'bcryptjs'
import * as z from 'zod'
import { getPasswordResetTokenByToken } from '@/data/password-reset-token'
import { getUserByEmail } from '@/data/user'
import db from '@/lib/db'
import { NewPasswordSchema } from '@/schema'
export const newPassword = async (
values: z.infer<typeof NewPasswordSchema>,
token?: string | null,
) => {
if (!token) {
return { error: 'Token not found.' }
}
const validatedFields = NewPasswordSchema.safeParse(values)
if (!validatedFields.success) {
return { error: 'Please correct the input.' }
}
const existingToken = await getPasswordResetTokenByToken(token)
if (!existingToken) {
return { error: 'Token not found.' }
}
const hasExpired = new Date(existingToken.expires) < new Date()
if (hasExpired) {
return { error: 'Token has expired.' }
}
const existingUser = await getUserByEmail(existingToken.email)
if (!existingUser) {
return { error: 'Email address does not exist.' }
}
const { password } = validatedFields.data
const hashedPassword = await bcrypt.hash(password, 10)
try {
await db.user.update({
where: { id: existingUser.id },
data: { password: hashedPassword },
})
return {
success:
'Password updated. Please log in from the login screen.',
}
} catch {
return { error: 'An error occurred.' }
}
}
Let’s go through the code.
const validatedFields = NewPasswordSchema.safeParse(values)
if (!validatedFields.success) {
return { error: 'Please correct the input.' }
}
const existingToken = await getPasswordResetTokenByToken(token)
if (!existingToken) {
return { error: 'Token not found.' }
}
const hasExpired = new Date(existingToken.expires) < new Date()
if (hasExpired) {
return { error: 'Token has expired.' }
}
Here we are checking three things:
① Is the received password valid? (Specifically, is it at least 6 characters and do password and confirmPassword match?)
② Is the token passed to the server action one that has actually been generated?
③ Is the token still within its validity period?
If any of these conditions are not met, we immediately return an error.
export const newPassword = async (
values: z.infer<typeof NewPasswordSchema>,
token?: string | null,
) => {
const { password } = validatedFields.data
const hashedPassword = await bcrypt.hash(password, 10)
The data entered by the user comes in as values, so we extract password from it.
After hashing the password, we store it in the database.
This flow is the same as what we implemented earlier for account registration.
try {
await db.user.update({
where: { id: existingUser.id },
data: { password: hashedPassword },
})
return {
success:
'Password updated. Please log in from the login screen.',
}
} catch {
return { error: 'An error occurred.' }
}
After that, we update the password and, if there are no errors, return a success message to the client side.
Implementing the Screen
Next, we’ll implement the screen where the user enters a new password.
First, we’ll create the form for entering the new password.
Create the /components/auth/new-password-form.tsx file and write the following code.
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
import { useSearchParams } from 'next/navigation'
import { useState, useTransition } from 'react'
import { useForm } from 'react-hook-form'
import * as z from 'zod'
import { newPassword } from '@/actions/new-password'
import { CardWrapper } from '@/components/auth/card-wrapper'
import { FormError } from '@/components/form-error'
import { FormSuccess } from '@/components/form-success'
import { Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { NewPasswordSchema } from '@/schema'
export const NewPasswordForm = () => {
const searchParams = useSearchParams()
const token = searchParams.get('token')
const [error, setError] = useState<string | undefined>('')
const [success, setSuccess] = useState<string | undefined>('')
const [isPending, startTransition] = useTransition()
const form = useForm<z.infer<typeof NewPasswordSchema>>({
resolver: zodResolver(NewPasswordSchema),
defaultValues: {
password: '',
confirmPassword: '',
},
})
const onSubmit = (values: z.infer<typeof NewPasswordSchema>) => {
setError('')
setSuccess('')
startTransition(() => {
newPassword(values, token).then((data) => {
setError(data?.error)
setSuccess(data?.success)
})
})
}
return (
<CardWrapper
headerLabel="Set New Password"
buttonLabel="Go to Login Screen"
buttonHref="/auth/login"
>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-4">
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>New Password</FormLabel>
<FormControl>
<Input
{...field}
disabled={isPending}
placeholder="******"
type="password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="confirmPassword"
render={({ field }) => (
<FormItem>
<FormLabel>Confirm Password</FormLabel>
<FormControl>
<Input
{...field}
disabled={isPending}
placeholder="******"
type="password"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormError message={error} />
<FormSuccess message={success} />
<Button disabled={isPending} type="submit" className="w-full">
Set New Password
</Button>
</form>
</Form>
</CardWrapper>
)
}
const searchParams = useSearchParams()
const token = searchParams.get('token')
Here we are getting the token from the URL.
In the previous article, we generated the URL in the following format:
const resetLink = `${domain}/auth/new-password?token=${token}`
From this, we retrieve the string after ?token= using searchParams.get('token').
const [error, setError] = useState<string | undefined>('')
const [success, setSuccess] = useState<string | undefined>('')
const onSubmit = (values: z.infer<typeof NewPasswordSchema>) => {
setError('')
setSuccess('')
startTransition(() => {
newPassword(values, token).then((data) => {
setError(data?.error)
setSuccess(data?.success)
})
})
}
When the user enters a password and clicks the “Set new password” button, the server action we just created is called.
We manage the success and error messages with useState so that they can be displayed on the screen when they occur.
return (
<CardWrapper
headerLabel="Set New Password"
buttonLabel="Go to Login Screen"
buttonHref="/auth/login"
>
After the password has been successfully updated, the user will log in via the login screen, so we provide a link to the login page.
Finally, we’ll implement the page using this input form.
Create the /app/auth/new-password/page.tsx file and write the following code.
The directory needs to match the link.
This time we defined the link as shown below, so the directory will be /auth/new-password.
const resetLink = `${domain}/auth/new-password?token=${token}`
import { NewPasswordForm } from '@/components/auth/new-password-form'
const NewPasswordPage = () => {
return <NewPasswordForm />
}
export default NewPasswordPage
Operation Check
Now that we’ve finished the necessary implementation, let’s actually access the screen and check that the password can be updated.
Access the screen using the email sent in the previous article. When you click the link in the email, the screen we created this time should be displayed. After entering a password and clicking the “Set new password” button, a message saying “Your password has been updated. Please log in from the login screen.” will be displayed.
Then confirm that you can log in with the new password from the login screen.
Let’s also check that password validation is working.
If you enter a 5-character password and click the “Set new password” button, an error will be displayed.
Also, if you set different values for the new password and the confirmation password and click the “Set new password” button, an error will be displayed.
Conclusion
Across these two articles, we implemented a password reset feature. Since password reset is assumed to be used while the user is logged out, you also need to configure routing so that it can be accessed in a logged-out state. You must always consider in what state the user will use each feature when implementing it, so it may be helpful to revisit middleware functionality and Auth.js, which lets you obtain authentication state, as needed.
Recommended Article
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
Chat 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/02Tutorial for Implementing Authentication with Next.js and Auth.js
2024/09/13Thorough Comparison of the Best ORMs for the Next.js App Router: How to Choose Between Prisma / Drizzle / Kysely / TypeORM [Part 1]
2025/03/13Test Strategy in the Next.js App Router Era: Development Confidence Backed by Jest, RTL, and Playwright
2025/04/22Done in 10 minutes. Easy deployment procedure for a Next.js app using the official AWS Amplify template
2024/11/05Building an Integrated Next.js × AWS CDK Environment: From Local Development with Docker to Production Deployment
2024/05/11


