How to Set Up Secure Forgot Password/ Reset in Node.js and Express

How to Set Up Secure Forgot Password/ Reset in Node.js and Express

·

12 min read

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:

  1. User requests a forget password by clicking on “Forget Password” link.

  2. User provides his email id (userid).

  3. If the email exists in the User database, generate a unique token and save it to the database.

  4. Send an email to the user with a link containing the token.

  5. User clicks the link.

    1. Check the token expiry from the database.

    2. Match the saved token in the database.

    3. If token matches, redirect user to change password page.

    4. If not match or token expired, show error message.

  6. User enters new password and confirm password in the form.

  7. Validate the token and update the password in the database.

Steps for Forget Password Implementation

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>

postman test forgot-password

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

Mail received with password reset token

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

  1. Token is securely generated using crypto.randomBytes()

  2. Token is hashed before storage. Prevents leaked token.

  3. Tokens expire after 30 minutes

  4. Used tokens are deleted after password reset

  5. Generic responses prevent email enumeration

  6. 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

Security Issues and solutions

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.

  1. 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.