This article provides a complete implementation of a secure forget password system in NodeJs using express, mongoose. This feature is essential for user account security, allowing users to reset their passwords securely if they forget them. We will cover the necessary steps, including generating a password reset token, sending it via email, and updating the password. The code is organized in logical sections with security considerations explained. You can use this as a reference for future projects.
Overview
The process of implementing a "Forget Password" feature typically involves the following steps:
User requests a forget password by clicking on “Forget Password” link.
User provides his email id (userid).
If the email exists in the User database, generate a unique token and save it to the database.
Send an email to the user with a link containing the token.
User clicks the link.
Check the token expiry from the database.
Match the saved token in the database.
If token matches, redirect user to change password page.
If not match or token expired, show error message.
User enters new password and confirm password in the form.
Validate the token and update the password in the database.
Features
Secure random token generation
Token hashing before database storage
Email delivery with reset links
Token expiration
Password validation and update
Prerequisites
Before we begin, ensure you have the following:
Node.js installed on your machine.
A MongoDB database set up.
Basic knowledge of Express.js and Mongoose.
An email service (like Nodemailer) for sending emails.
Step 1: Setting Up the Project
First, create a new Node.js project and install the required packages:
mkdir password-reset
cd password-reset
npm init -y
npm install express mongoose nodemailer crypto dotenv googleapis rateLimit
Step 2: Create the Models (User and PasswordResetToken)
Define a User model using Mongoose. This model will include fields for the user's email, password, and other fields required in your registration form. I have used mongoose pre.save() hook to hash the password before saving/modifying to the database. This can be done within the controller also.
const mongoose = require("mongoose");
const bcrypt = require("bcrypt");
const jwt = require("jsonwebtoken");
const userSchema = new mongoose.Schema({
{
name: {
type: String,
required: true,
},
role: {
type: String,
default: "user",
},
email: {
type: String,
required: [true, "Email is required"],
unique: true,
lowercase: true,
trim: true,
},
password: {
type: String,
required: [true, "Password is required"],
},
status: {
type: String,
default: "active",
},
},
{ timestamps: true }
});
//Hash password before save
userSchema.pre("save", async function (next) {
const saltRounds = 10;
if (this.isModified("password") || this.isNew) {
try {
const hashedPassword = await bcrypt.hash(this.password, saltRounds);
this.password = hashedPassword;
next();
} catch (err) {
next(new Error("Encryption error: " + err.message));
}
} else {
next();
}
});
const User = mongoose.model("User", userSchema);
module.exports = User;
// Define the schema for reset tokens
const mongoose = require("mongoose");
const passwordResetTokenSchema = new mongoose.Schema({
email: {
type: String,
required: true,
},
token: {
type: String, // Hashed version of the token
required: true,
},
expiresAt: {
type: Date,
required: true,
},
});
passwordResetTokenSchema.index({ email: 1 }); // To optimize email lookups
module.exports = mongoose.model("PasswordResetToken", passwordResetTokenSchema);
Step 3: Implementing the Password Reset Workflow
Requesting a Password Reset
When a user requests a password reset, they'll provide their email address.
//app.js route to get forgot-password
app.get("/forgot-password", (req, res) => {
res.render("forgot-password");
});
//Forgot-password.ejs form to collect user's email id
<form action="/forgot-password" method="POST">
<h1>Forgot Password</h1>
<div>
<label for="email">Email Id</label>
<input type="email" name="email" required />
</div>
<button type="submit">Get Password Reset Link</button>
</form>
Generating , Storing the Token and Sending Password Reset Link
We'll verify if this email exists in our database. If the user exists, generate a secure token, hash it, and store it in the database with an expiration time. Use Nodemailer to send an email containing the password reset link with the token.
If you want to look for code to send mail via nodemailer and nodeJs, I have already written a detailed article here (How to Send Emails in Node.js Using Gmail, Nodemailer, and OAuth2).
// app.js - Rate limiter to prevent abuse
const forgotPasswordLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // limit each IP to 5 requests per windowMs
message: {
success: false,
message: "Too many requests. Please try again later.",
},
});
const bcrypt = require("bcrypt");
const PasswordResetToken = require("./modals/passwordResetToken.modals.js");
const sendEmail = require("./config/gmail.js");
// Forgot password route - Generate and send reset token
app.post("/forgot-password", forgotPasswordLimiter, async (req, res) => {
try {
const { email } = req.body;
//If no email show error
if (!email) {
return res.status(400).json({
success: false,
message: "Email is required.",
});
}
//Check email in the db
const user = await User.findOne({ email });
if (user) {
// Generate a secure random token
const resetToken = crypto.randomBytes(64).toString("hex");
// Hash the token before saving to the database
const hashedToken = await bcrypt.hash(resetToken, 10);
// Set token expiry (e.g., 30 minutes from now)
const expiresAt = Date.now() + 1800 * 1000;
// Save the hashed token to the database
await new PasswordResetToken({
email,
token: hashedToken,
expiresAt,
}).save();
// Generate reset link
const resetLink = `${req.protocol}://${req.get(
"host"
)}/reset-password?token=${resetToken}`;
const subject = "Password Reset Request";
const text = `You are receiving this because you (or someone else) have requested the reset of the password for your account. The link is valid for 1 hr.\n\n
Please click on the following link, or paste this into your browser to complete the process:\n\n ${resetLink} \n\n
If you did not request this, please ignore this email and your password will remain unchanged.\n`;
const html = `<p>You are receiving this because you (or someone else) have requested the reset of the password for your account. <u>The link is valid for 1 hr.</u>\n\n
Please click on the following link, or paste this into your browser to complete the process:</p> \n\n
<a href="${resetLink}">${resetLink}</a> \n\n
<p> If you did not request this, please ignore this email and your password will remain unchanged.\n</p>`;
// Send email to the user
await sendEmail(email, subject, text, html);
console.log("Password reset email sent successfully");
}
// Respond with success message regardless of email existence
return res.status(200).json({
success: true,
message: "If the email exists, a password reset link has been sent.",
});
} catch (error) {
console.log("Error sending password reset email:", error);
return res.status(400).json({
success: false,
message: "Something went wrong. Please try again later.",
});
}
});
Mail received with link for new password
Handling the Password Reset Link
When the user clicks the link, verify the token's validity and expiration.
app.get("/reset-password", async (req, res) => {
const { token } = req.query;
if (!token) {
return res.status(400).json({
success: false,
message: "Password reset token is required.",
});
}
try {
// Fetch unexpired tokens
const resetTokens = await PasswordResetToken.find({
expiresAt: { $gt: Date.now() },
});
// Find matching token using bcrypt.compare
const matchingToken = await resetTokens.reduce(
async (matchPromise, entry) => {
const match = await matchPromise;
if (match) return match;
const isMatch = await bcrypt.compare(token, entry.token);
return isMatch ? entry : null;
},
Promise.resolve(null)
);
if (!matchingToken) {
return res.status(400).json({
success: false,
message: "Invalid or expired token.",
});
}
res.render("reset-password", {
user: req.user,
token,
});
} catch (error) {
console.error(error);
return res.status(500).json({
success: false,
message: "An error occurred. Please try again later.",
});
}
});
If token is valid a form is opened for setting new password.
<form action="/reset-password" method="POST">
<input type="hidden" name="token" value="<%= token %>" />
<div>
<label for="password">New Password</label>
<input type="password" name="password" required minlength="8" />
</div>
<div>
<label for="confirmPassword">Confirm Password</label>
<input type="password" name="confirmPassword" required minlength="8" />
</div>
<button type="submit">Reset Password</button>
</form>
Updating the Password
Finally, the new password is validated and saved to the database.
//cleanup function to delete all existing tokens for the user mail id
async function deleteAllTokensForUser(email) {
await PasswordResetToken.deleteMany({ email });
}
// Reset password route
app.post("/reset-password", async (req, res) => {
const { token, password, confirmPassword } = req.body;
if (!token || !password) {
return res.status(400).json({
success: false,
message: "Token and new password are required.",
});
}
if (password !== confirmPassword) {
return res.status(400).json({
success: false,
message: "Passwords do not match",
});
}
try {
// Fetch unexpired tokens
const resetTokens = await PasswordResetToken.find({
expiresAt: { $gt: Date.now() },
});
// Find matching token using bcrypt.compare
const matchingToken = await resetTokens.reduce(
async (matchPromise, entry) => {
const match = await matchPromise;
if (match) return match;
const isMatch = await bcrypt.compare(token, entry.token);
return isMatch ? entry : null;
},
Promise.resolve(null)
);
if (!matchingToken) {
return res.status(400).json({
success: false,
message: "Invalid or expired token.",
});
}
// Hash and update the new password for the user
const user = await User.findOne({ email: matchingToken.email });
user.password = password;
await user.save();
// Delete all tokens for the user
await deleteAllTokensForUser(matchingToken.email);
return res.status(200).json({
success: true,
message: "Password successfully reset.",
});
} catch (err) {
console.error(err);
return res.status(500).json({
success: false,
message: "An error occurred. Please try again later.",
});
}
});
Note on Password Hashing
When updating passwords, use save()
instead of findOneAndUpdate()
to trigger Mongoose pre-save middleware for password hashing:
userSchema.pre("save", async function (next) {
const saltRounds = 10;
if (this.isModified("password") || this.isNew) {
try {
const hashedPassword = await bcrypt.hash(this.password, saltRounds);
this.password = hashedPassword;
next();
} catch (err) {
next(new Error("Encryption error: " + err.message));
}
} else {
next();
}
});
findOneAndUpdate
bypasses Mongoose middleware. So needed to use find and save separately.
// Hash and update the new password for the user
const user = await User.findOne({ email: matchingToken.email });
user.password = password;
await user.save();Security Considerations
Security Considerations
Token is securely generated using crypto.randomBytes()
Token is hashed before storage. Prevents leaked token.
Tokens expire after 30 minutes
Used tokens are deleted after password reset
Generic responses prevent email enumeration
Password confirmation prevents typos
Pre-save middleware ensures password hashing
The code above incorporates several security measures to address common vulnerabilities associated with password reset processes. Here's how it aligns with best practices:
Security Issues and Steps taken to mitigate them
Token Predictability Vulnerability
The Risk: If reset tokens are predictable or follow a pattern, attackers could guess valid tokens and reset other users' passwords. For example, using sequential numbers or timestamps as tokens would be highly insecure. Even JWT tokens are not that secure when sent to client.
Our Solution:
const resetToken = crypto.randomBytes(64).toString("hex");
We use Node's crypto.randomBytes() to generate a cryptographically secure random token of 64 bytes (128 characters when converted to hex). This approach ensures that the token has high entropy, making it resistant to brute force attacks. Best practices recommend using tokens that are sufficiently long and generated from a secure random source to prevent attackers from guessing them.
Token Storage Vulnerability
The Risk: Storing reset tokens in plain text means if an attacker gains access to the database, they could use any valid token to reset passwords. It's similar to storing passwords in plain text.
Our Solution:
const hashedToken = await bcrypt.hash(resetToken, 10);
await new PasswordResetToken({
email,
token: hashedToken,
expiresAt,
}).save();
Storing the hashed version of the token ensures that, even if the database is compromised, attackers cannot use the tokens to reset user passwords without first reversing the hash, which is computationally infeasible.
Token Expiration Vulnerability
The Risk: Without expiration, compromised tokens would remain valid indefinitely, giving attackers unlimited time to attempt password resets.
Our Solution:
const expiresAt = Date.now() + 1800 * 1000; // 30 minutes
// In verification:
await PasswordResetToken.find({
expiresAt: { $gt: Date.now() },
});
Implementing token expiration limits the window of opportunity for an attacker to use a stolen token, thereby enhancing security. It's essential to set a reasonable expiration time to balance security and user convenience. We implement token expiration by setting a 30-minute lifetime for each token, checking expiration before allowing password resets, and automatically invalidating tokens after the time limit.
Token Reuse Vulnerability
The Risk: If tokens remain valid after use, an attacker who intercepts a token could still use it even after the legitimate user has reset their password.
Our Solution:
// After successful password reset:
async function deleteAllTokensForUser(email) {
await PasswordResetToken.deleteMany({ email });
}
We prevent token reuse by deleting all the user's tokens immediately after a successful password reset, ensuring each token can only be used once, and preventing replay attacks with stolen tokens.
User Enumeration Vulnerability
The Risk: Telling attackers whether an email exists in the system helps them identify valid targets for attacks.
Our Solution:
return res.status(200).json({
success: true,
message: "If the email exists, a password reset link has been sent.",
});
We prevent enumeration by always returning the same success message, not indicating whether the email exists, and maintaining consistent response times regardless of email validity.
Brute Force and Denial of Service Vulnerability
The Risk: Without rate limiting, attackers can exploit password reset endpoints in several dangerous ways:
Brute Force Attacks: An attacker could flood an inbox with reset emails, making the real reset email hard to find and causing spam blocks.
Denial of Service (DoS): Attackers might overload the server with reset requests, consuming resources and causing service disruption.
Email Bombing: Malicious actors could flood inboxes, making them unusable and hiding important emails.
Our Solution:
const rateLimit = require('express-rate-limit'); // Rate limiter to prevent abuse const forgotPasswordLimiter = rateLimit({ windowMs: 15 * 60 * 1000, // 15 minutes max: 5, // limit each IP to 5 requests per windowMs message: { success: false, message: "Too many requests. Please try again later.", }, }); // Apply rate limiters to routes app.post("/forgot-password", forgotPasswordLimiter, async (req, res) => { // Existing forgot password logic });
Our rate limiting implementation provides protection by using tiered rate limiting, which limits forgot password requests to 5 per 15 minutes. It serves as an important defense against automated attacks, keeping the service available and secure for real users. Without these limits, even our other security measures like token hashing and expiration could be overrun by many malicious requests.
Understanding these vulnerabilities and their solutions is crucial for building secure password reset systems. Each security measure addresses a specific attack vector that could be exploited by malicious actors. By implementing these protections, we create multiple layers of security that work together to protect user accounts.
This article outlines the implementation of a secure "Forget Password" system in Node.js using Express and Mongoose. Key steps include generating and storing hashed reset tokens, sending reset links via email, and validating before updating passwords. The approach emphasizes security with features like secure random token generation, token expiration, and hashing, alongside rate limiting to prevent abuse and user enumeration. The guide includes code snippets and best practices for all steps in the password reset process.
Please like the article if you found it helpful, and share your thoughts in the comments.