NextAuth.js Authentication Series — Part 11 ~ Building a Secure Forgot & Reset Password System with Auth.js
In the previous article, we implemented role-based authentication and authorization. Our application can now distinguish between regular users and...

In the previous article, we implemented role-based authentication and authorization. Our application can now distinguish between regular users and administrators and protect resources based on their roles.
However, there's still an important feature missing from almost every real-world authentication system:
What happens when a user forgets their password?
Users shouldn't need to create a new account simply because they can't remember their password.
Instead, we need a secure password recovery flow.
In this article, we'll build a Forgot Password and Reset Password system using Next.js, MongoDB, bcrypt, and a secure reset token.
By the end of this article, users will be able to:
Request a password reset
Receive a password reset link
Open a secure reset page
Set a new password
Have the new password hashed with bcrypt
Use the new password to sign in
How Password Reset Works
A typical password reset flow looks like this:
User
│
▼
Forgot Password
│
▼
Enter Email
│
▼
Generate Secure Token
│
▼
Store Token + Expiration
│
▼
Send Reset Email
│
▼
User Opens Link
│
▼
Validate Token
│
▼
Enter New Password
│
▼
Hash New Password
│
▼
Update User
│
▼
Delete Reset Token
The important point is that the reset token should be:
Random
Hard to guess
Short-lived
Single-use
Step 1 — Create a Password Reset Token Collection
We'll store password reset information separately from the user document.
A reset token document might look like:
{
"userId": "...",
"tokenHash": "...",
"expiresAt": "...",
"createdAt": "..."
}
We should not store the raw reset token if we can avoid it.
Instead, we'll generate a random token, send the raw token to the user by email, and store a hash of that token in MongoDB.
Step 2 — Create a Secure Token Generator
Node.js provides a cryptographically secure random generator.
Create:
lib/reset-token.ts
import { createHash, randomBytes } from "crypto";
export function generateResetToken() {
const token = randomBytes(32).toString("hex");
const tokenHash = createHash("sha256")
.update(token)
.digest("hex");
return {
token,
tokenHash,
};
}
This produces two values:
token
tokenHash
The raw token will be included in the email.
The tokenHash will be stored in MongoDB.
Step 3 — Set an Expiration Time
Password reset links shouldn't remain valid forever.
For example:
const expiresAt = new Date(
Date.now() + 1000 * 60 * 15
);
This creates a 15-minute expiration period.
So the reset token becomes:
Created
│
▼
Valid for 15 minutes
│
▼
Expired
A shorter lifetime reduces the window in which a leaked reset link could be abused.
Step 4 — Create the Forgot Password API
Create:
app/api/auth/forgot-password/route.ts
import { NextResponse } from "next/server";
import { connectDB } from "@/lib/mongodb";
import { generateResetToken } from "@/lib/reset-token";
export async function POST(request: Request) {
try {
const { email } = await request.json();
if (!email) {
return NextResponse.json(
{
message: "Email is required",
},
{ status: 400 }
);
}
const db = await connectDB();
const user = await db.collection("users").findOne({
email,
});
if (!user) {
return NextResponse.json({
message:
"If an account exists, a reset link has been sent.",
});
}
const { token, tokenHash } = generateResetToken();
const expiresAt = new Date(
Date.now() + 1000 * 60 * 15
);
await db.collection("passwordResetTokens").deleteMany({
userId: user._id,
});
await db.collection("passwordResetTokens").insertOne({
userId: user._id,
tokenHash,
expiresAt,
createdAt: new Date(),
});
const resetUrl =
`${process.env.NEXTAUTH_URL}/reset-password?token=${token}`;
/*
Send resetUrl by email here.
*/
console.log("Password reset URL:", resetUrl);
return NextResponse.json({
message:
"If an account exists, a reset link has been sent.",
});
} catch (error) {
console.error(error);
return NextResponse.json(
{
message: "Something went wrong",
},
{ status: 500 }
);
}
}
Notice that we return the same message whether or not the email exists.
This helps reduce account enumeration.
Step 5 — Why We Hash the Reset Token
Suppose we stored this:
token = abc123...
If someone obtains access to the reset-token collection, they could potentially use active tokens.
Instead, we store:
SHA-256(token)
The user receives:
https://example.com/reset-password?token=abc123...
When they submit the token, we hash it again:
Incoming Token
↓
SHA-256
↓
Compare with Database
This gives us an additional layer of protection.
Step 6 — Create the Forgot Password Page
Create:
app/forgot-password/page.tsx
"use client";
import { FormEvent, useState } from "react";
export default function ForgotPasswordPage() {
const [email, setEmail] = useState("");
const [message, setMessage] = useState("");
async function handleSubmit(
event: FormEvent<HTMLFormElement>
) {
event.preventDefault();
setMessage("");
const response = await fetch(
"/api/auth/forgot-password",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email }),
}
);
const data = await response.json();
setMessage(data.message);
}
return (
<main>
<h1>Forgot Password?</h1>
<form onSubmit={handleSubmit}>
<input
type="email"
placeholder="Enter your email"
value={email}
onChange={(event) =>
setEmail(event.target.value)
}
required
/>
<button type="submit">
Send Reset Link
</button>
</form>
{message && <p>{message}</p>}
</main>
);
}
The user can now visit:
/forgot-password
and submit their email address.
Step 7 — Sending the Reset Email
In a real application, we shouldn't use:
console.log(resetUrl);
Instead, the reset URL should be sent through an email provider.
For example:
User
│
▼
Forgot Password
│
▼
Generate Token
│
▼
Email Service
│
▼
User's Inbox
You can use an email service such as:
Resend
Amazon SES
SendGrid
Postmark
SMTP
The actual provider is less important than following the correct security flow.
The email should contain a link similar to:
https://yourdomain.com/reset-password?token=...
Step 8 — Create the Reset Password Page
Create:
app/reset-password/page.tsx
"use client";
import { FormEvent, useState } from "react";
import { useSearchParams } from "next/navigation";
export default function ResetPasswordPage() {
const searchParams = useSearchParams();
const token = searchParams.get("token");
const [password, setPassword] = useState("");
const [message, setMessage] = useState("");
async function handleSubmit(
event: FormEvent<HTMLFormElement>
) {
event.preventDefault();
if (!token) {
setMessage("Invalid reset link.");
return;
}
const response = await fetch(
"/api/auth/reset-password",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
token,
password,
}),
}
);
const data = await response.json();
setMessage(data.message);
}
return (
<main>
<h1>Reset Password</h1>
<form onSubmit={handleSubmit}>
<input
type="password"
placeholder="New password"
value={password}
onChange={(event) =>
setPassword(event.target.value)
}
required
/>
<button type="submit">
Reset Password
</button>
</form>
{message && <p>{message}</p>}
</main>
);
}
Step 9 — Create the Reset Password API
Create:
app/api/auth/reset-password/route.ts
import { createHash } from "crypto";
import { NextResponse } from "next/server";
import { connectDB } from "@/lib/mongodb";
import { hashPassword } from "@/lib/password";
export async function POST(request: Request) {
try {
const { token, password } = await request.json();
if (!token || !password) {
return NextResponse.json(
{
message: "Invalid request",
},
{ status: 400 }
);
}
if (password.length < 8) {
return NextResponse.json(
{
message:
"Password must be at least 8 characters",
},
{ status: 400 }
);
}
const tokenHash = createHash("sha256")
.update(token)
.digest("hex");
const db = await connectDB();
const resetToken =
await db.collection("passwordResetTokens").findOne({
tokenHash,
expiresAt: {
$gt: new Date(),
},
});
if (!resetToken) {
return NextResponse.json(
{
message:
"This reset link is invalid or expired.",
},
{ status: 400 }
);
}
const hashedPassword = await hashPassword(password);
await db.collection("users").updateOne(
{
_id: resetToken.userId,
},
{
$set: {
password: hashedPassword,
updatedAt: new Date(),
},
}
);
await db.collection("passwordResetTokens").deleteOne({
_id: resetToken._id,
});
return NextResponse.json({
message:
"Password has been reset successfully.",
});
} catch (error) {
console.error(error);
return NextResponse.json(
{
message: "Something went wrong",
},
{ status: 500 }
);
}
}
Step 10 — Understanding the Reset Process
When the user submits a new password:
Reset Token
│
▼
Hash Token
│
▼
Find Token in MongoDB
│
▼
Check Expiration
│
▼
Find User
│
▼
Hash New Password
│
▼
Update User
│
▼
Delete Reset Token
The reset token is then no longer usable.
This makes the token effectively single-use.
Step 11 — Delete Expired Tokens
Expired tokens shouldn't remain in the database forever.
MongoDB supports TTL indexes, which can automatically remove expired documents.
Create a TTL index:
await db.collection("passwordResetTokens").createIndex(
{ expiresAt: 1 },
{ expireAfterSeconds: 0 }
);
MongoDB will automatically remove documents after their expiresAt time.
This is a clean way to manage temporary reset tokens.
Step 12 — Invalidate Existing Sessions
Changing a password raises another security question:
What happens to sessions that were already active?
For sensitive applications, you may want to invalidate existing sessions after a successful password reset.
The exact implementation depends on the session strategy.
With JWT-based sessions, immediate global invalidation is more involved because previously issued tokens may remain valid until they expire or are otherwise invalidated.
A production system may use:
sessionVersion
or another server-side mechanism to invalidate older sessions after security-sensitive events.
We'll discuss this further when covering production security.
Security Best Practices
A password reset system should follow several rules.
1. Use Cryptographically Secure Tokens
Use:
randomBytes()
rather than predictable values.
2. Set a Short Expiration
For example:
15 minutes
3. Store a Hash of the Token
Don't unnecessarily store the raw reset token.
4. Make Tokens Single-Use
Delete the token after a successful reset.
5. Don't Reveal Whether an Account Exists
Use a generic response such as:
If an account exists, a reset link has been sent.
6. Hash the New Password
Always use the same secure password hashing process used during registration.
7. Rate Limit the Endpoint
Password-reset requests should be rate limited to prevent abuse.
Complete Password Recovery Flow
Our system now looks like:
Forgot Password
│
▼
Enter Email
│
▼
Generate Token
│
▼
Store Hash
│
▼
Send Email
│
▼
Reset Password
│
▼
Validate Token
│
▼
Hash Password
│
▼
Update MongoDB
│
▼
Delete Reset Token
Project Structure
Our project now contains:
app/
├── api/
│ └── auth/
│ ├── [...nextauth]/
│ ├── register/
│ ├── forgot-password/
│ └── reset-password/
│
├── forgot-password/
│ └── page.tsx
│
├── reset-password/
│ └── page.tsx
│
├── dashboard/
├── admin/
└── register/
lib/
├── auth.ts
├── authorization.ts
├── mongodb.ts
├── password.ts
├── reset-token.ts
├── roles.ts
└── validations/
└── auth.ts
Key Takeaways
In this article, we learned how to:
Build a forgot-password flow.
Generate secure random reset tokens.
Hash reset tokens before storing them.
Set token expiration.
Send reset links through email.
Validate reset tokens.
Hash new passwords with bcrypt.
Delete reset tokens after successful use.
Automatically remove expired tokens with MongoDB TTL indexes.
Reduce account enumeration risks.
Think about session invalidation after password changes.
Our authentication system is becoming much more complete:
Registration
↓
bcrypt
↓
MongoDB
↓
Login
↓
JWT Session
↓
Protected Routes
↓
Role-Based Authorization
↓
Forgot Password
↓
Reset Password
What's Next?
Our users can now register, log in, protect their accounts, and recover forgotten passwords.
However, there's another important problem.
How do we know that the email address provided during registration actually belongs to the user?
A production authentication system often requires email verification before allowing certain actions.
In the next article, we'll build an Email Verification System.
We'll cover:
Verification tokens
Token expiration
Verification emails
Verifying a user's email
Updating
emailVerifiedPreventing unverified users from accessing selected features
Handling expired verification links
Series Navigation
NextAuth.js Authentication Series
✅ Part 1 — Introduction to Authentication with NextAuth.js
✅ Part 2 — Installing & Configuring NextAuth.js
✅ Part 3 — Google OAuth Authentication
✅ Part 4 — Credentials Provider with Email & Password
✅ Part 5 — Integrating MongoDB with Credentials Authentication
✅ Part 6 — Secure Password Authentication with bcrypt
✅ Part 7 — Building a User Registration System
✅ Part 8 — JWT & Session Management
✅ Part 9 — Protecting Routes with Auth.js
✅ Part 10 — Role-Based Authentication & Authorization
✅ Part 11 — Forgot & Reset Password ← You are here
🔜 Part 12 — Email Verification
🔜 Part 13 — Production Deployment & Security Best Practices


