How to Implement Password Reset in Node.js

Editorial Note: We may earn a commission when you visit links on our website.

Do you want to add a secure password reset feature to your Node.js application?

Whether you’re building a user authentication system for a web app, SaaS platform, or enterprise application, implementing a reliable password reset flow is essential for user experience and security.

In this guide, you’ll learn how to implement a complete password reset system in Node.js using Express, MongoDB, and email notifications.

What Is a Password Reset Flow?

A password reset flow is a security feature that allows users to regain access to their accounts when they forget their passwords. Instead of storing or sending the old password, the system generates a unique, time-limited token that authorizes the user to create a new password.

Here’s how the typical password reset workflow works:

  • User initiates request: The user clicks “Forgot Password” and enters their email address
  • System generates token: The server creates a unique secure token
  • Email sent: The token is sent via email with a reset link to the user
  • User clicks the link: The reset link redirects to a password change form
  • Token validated: The system verifies that the token is valid and not expired
  • Password updated: User enters new password, and it’s saved to the database
  • Confirmation sent: A confirmation email notifies the user of the password change
Password reset flow diagram

Sounds easy, right? Well, a lot of work goes on under the hood to make sure this flow is secure. The token-based approach prevents attackers from accessing accounts even if they intercept the reset email.

Prerequisites

Before we start building, make sure you have the following requirements:

  • MongoDB database (local installation or MongoDB Atlas)
  • Code editor: I recommend using Visual Studio Code
  • Basic knowledge of JavaScript, Node.js, and Express
  • Email API: I’m using SendLayer for this tutorial. You can get started for free
  • 200 Free Emails
  • Easy Setup
  • 5 Star Support

After creating your SendLayer account, make sure to authorize your sending domain. This step is essential for improving email deliverability and ensuring your password reset emails reach users’ inboxes instead of spam folders.

How to Implement Password Reset in Node.js

In this section, I’ll walk you through building a complete password reset system from scratch. We’ll use Express for the server, MongoDB for data storage, and SendLayer for sending emails.

Step 1: Set Up Your Node.js Project

First, open a terminal window and create a new directory for your project.

mkdir password-reset-app

Then navigate into the folder and initialize it:

cd password-reset-app && npm init -y

Now, let’s install the required dependencies:

npm install express mongoose bcryptjs jsonwebtoken dotenv validator cors

Let’s also install the development dependency for auto-restarting the server:

npm install -D nodemon

Here’s what each package does:

  • express: Web framework for building the backend API
  • mongoose: MongoDB object modeling tool
  • bcryptjs: Library for hashing passwords and tokens
  • jsonwebtoken: For generating secure tokens
  • dotenv: Loads environment variables from .env file
  • validator: Input validation and sanitization
  • cors: Enables cross-origin requests from frontend
Password reset node.js backend toolkit

For sending emails, we’ll use SendLayer’s API. Go ahead and install the SendLayer SDK:

npm install sendlayer

After installing the dependencies, update your package.json scripts section:

{
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  }
}

Step 2: Configure Environment Variables

Create a .env file in your project root to store sensitive configuration:

# Server Configuration
PORT=5001
NODE_ENV=development

# Database
MONGODB_URI=mongodb://localhost:27017/password-reset-db

# JWT Secret (use a strong random string)
JWT_SECRET=your_super_secret_jwt_key_here

# SendLayer Configuration
SENDLAYER_API_KEY=your_sendlayer_api_key_here
[email protected]
FROM_NAME=Your App Name

# Frontend URL (for reset links)
FRONTEND_URL=http://localhost:3000

# Token Configuration
RESET_TOKEN_EXPIRY=900 # 15 minutes

Important: Never commit your .env file to version control. Add it to your .gitignore file.

To get your SendLayer API key, log in to your SendLayer account. Once logged in, click the Settings menu and select the API Keys tab.

Access the API Keys section

Then click the copy icon next to Default API key to copy it.

Copy API key

After copying the API key, switch back to your code editor and paste this key into the .env file as the SENDLAYER_API_KEY value.

Pro Tip: If you’re using SendLayer to send password reset emails, the sender email needs to be at the domain you’ve authorized in SendLayer. For instance, if you authorized example.com, your sender email should be [email protected].

Step 3: Set Up Database Connection

If your app supports user registration, you should already have a database configured. I’ll show you how to set up a database connection using MongoDB.

To start, create a config folder and add a db.js file to handle the MongoDB connection. After that, copy and paste the snippet below to the database file:

const mongoose = require('mongoose');

const connectDB = async () => {
  try {
    await mongoose.connect(process.env.MONGODB_URI);
    console.log('MongoDB connected successfully');
  } catch (error) {
    console.error('MongoDB connection error:', error.message);
    process.exit(1);
  }
};

module.exports = connectDB;

This function establishes a connection to your MongoDB database. If the connection fails, the application will exit with an error message.

Pro Tip: Make sure to update the MONGODB_URI value in the .env with your actual database URI.

Step 4: Create User and Token Models

Now let’s create the database schemas for users and reset tokens. Create a models folder and add two files.

First, create User.js and add the following snippets to it:

const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  email: {
    type: String,
    required: [true, 'Email is required'],
    unique: true,
    lowercase: true,
    trim: true,
  },
  password: {
    type: String,
    required: [true, 'Password is required'],
    minlength: 8,
  },
  name: {
    type: String,
    required: true,
  },
  createdAt: {
    type: Date,
    default: Date.now,
  },
});

// Hash password before saving
userSchema.pre('save', async function () {
  if (!this.isModified('password')) return;

  const salt = await bcrypt.genSalt(12);
  this.password = await bcrypt.hash(this.password, salt);
});

// Method to compare passwords
userSchema.methods.comparePassword = async function(candidatePassword) {
  return await bcrypt.compare(candidatePassword, this.password);
};

module.exports = mongoose.model('User', userSchema);

The User model includes a pre-save middleware that automatically hashes passwords before storing them. We use bcrypt with 12 salt rounds for strong password hashing.

Next, create PasswordReset.js for storing reset tokens:

const mongoose = require('mongoose');

const passwordResetSchema = new mongoose.Schema({
  userId: {
    type: mongoose.Schema.Types.ObjectId,
    required: true,
    ref: 'User',
  },
  token: {
    type: String,
    required: true,
  },
  createdAt: {
    type: Date,
    default: Date.now,
    expires: 900, // Token expires after 15 minutes (900 seconds)
  },
});

module.exports = mongoose.model('PasswordReset', passwordResetSchema);

The expires field creates a TTL (Time To Live) index that automatically deletes expired tokens from the database. This keeps your database clean without manual cleanup.

Step 5: Create Email Service

The next step is to implement email sending to allow users to receive password reset emails. You can set this up using SMTP or an email API. However, I recommend using an email API because it has better deliverability.

In a previous tutorial, I covered various methods for sending emails in JavaScript. For this tutorial, I’ll use the API method with SendLayer.

  • 200 Free Emails
  • Easy Setup
  • 5 Star Support

To get started, create a services folder and add emailService.js to handle email sending with SendLayer:

const { SendLayer } = require('sendlayer');
require('dotenv').config();

const sendlayer = new SendLayer(process.env.SENDLAYER_API_KEY);

const sendPasswordResetEmail = async (email, name, resetToken) => {
  const resetUrl = `${process.env.FRONTEND_URL}/reset-password/${resetToken}`;
  
  const htmlContent = `
    <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
      <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0;">
        <h1 style="margin: 0; font-size: 28px;">Password Reset Request</h1>
      </div>
      
      <div style="background: white; padding: 30px; border-radius: 0 0 8px 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
        <p style="font-size: 16px; color: #333; line-height: 1.6;">
          Hi <strong>${name}</strong>,
        </p>
        
        <p style="font-size: 16px; color: #333; line-height: 1.6;">
          We received a request to reset your password. Click the button below to create a new password:
        </p>
        
        <div style="text-align: center; margin: 30px 0;">
          <a href="${resetUrl}" style="background: #667eea; color: white; padding: 15px 30px; text-decoration: none; border-radius: 5px; font-weight: bold; display: inline-block;">
            Reset Password
          </a>
        </div>
        
        <p style="font-size: 14px; color: #666; line-height: 1.6;">
          This link will expire in <strong>15 minutes</strong> for security reasons.
        </p>
        
        <p style="font-size: 14px; color: #666; line-height: 1.6;">
          If you didn't request a password reset, please ignore this email or contact support if you have concerns.
        </p>
        
        <div style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #eee;">
          <p style="font-size: 12px; color: #999; margin: 0;">
            If the button doesn't work, copy and paste this link into your browser:<br>
            <a href="${resetUrl}" style="color: #667eea; word-break: break-all;">${resetUrl}</a>
          </p>
        </div>
      </div>
    </div>
  `;

  const textContent = `
    Hi ${name},
    
    We received a request to reset your password. Click the link below to create a new password:
    
    ${resetUrl}
    
    This link will expire in 15 minutes for security reasons.
    
    If you didn't request a password reset, please ignore this email or contact support if you have concerns.
  `;

  try {
    const response = await sendlayer.Emails.send({
      from: {
        email: process.env.FROM_EMAIL,
        name: process.env.FROM_NAME,
      },
      to: [{
        email: email,
        name: name,
      }],
      subject: 'Password Reset Request',
      html: htmlContent,
      text: textContent,
      tags: ['password-reset'],
    });

    console.log('Password reset email sent:', response);
    return response;
  } catch (error) {
    console.error('Error sending password reset email:', error);
    throw error;
  }
};

const sendPasswordChangeConfirmation = async (email, name) => {
  const htmlContent = `
    <div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
      <div style="background: #10b981; color: white; padding: 30px; text-align: center; border-radius: 8px 8px 0 0;">
        <h1 style="margin: 0; font-size: 28px;">✓ Password Changed Successfully</h1>
      </div>
      
      <div style="background: white; padding: 30px; border-radius: 0 0 8px 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
        <p style="font-size: 16px; color: #333; line-height: 1.6;">
          Hi <strong>${name}</strong>,
        </p>
        
        <p style="font-size: 16px; color: #333; line-height: 1.6;">
          Your password has been changed successfully. You can now log in with your new password.
        </p>
        
        <p style="font-size: 14px; color: #666; line-height: 1.6;">
          If you didn't make this change, please contact our support team immediately.
        </p>
      </div>
    </div>
  `;

  const textContent = `
    Hi ${name},
    
    Your password has been changed successfully. You can now log in with your new password.
    
    If you didn't make this change, please contact our support team immediately.
  `;

  try {
    await sendlayer.Emails.send({
      from: {
        email: process.env.FROM_EMAIL,
        name: process.env.FROM_NAME,
      },
      to: [{
        email: email,
        name: name,
      }],
      subject: 'Password Changed Successfully',
      html: htmlContent,
      text: textContent,
      tags: ['password-change-confirmation'],
    });
  } catch (error) {
    console.error('Error sending confirmation email:', error);
  }
};

module.exports = {
  sendPasswordResetEmail,
  sendPasswordChangeConfirmation,
};

This email service includes two functions: one for sending the reset link and another for confirming the password change. Both use responsive HTML templates that work across all email clients.

Pro Tip: SendLayer provides excellent email deliverability with domain reputation protection. It automatically creates a subdomain for transactional emails. This ensures your main domain stays protected from any potential deliverability issues.

When calling the sendPasswordResetEmail method, you’ll need to specify the required parameters: name, email, and resetToken. The sendPasswordChangeConfirmation method requires name and email as its arguments.

Step 6: Create Password Reset Controllers

Here’s where the magic happens. Let’s create the methods for generating the secure token. We’ll also use the 2 email methods we created here to send the token to the user’s email.

To start, create a controllers folder and add authController.js to the folder. Then copy and paste the snippet below into the authController.js file:

const crypto = require('crypto');
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const validator = require('validator');
const User = require('../models/User');
const PasswordReset = require('../models/PasswordReset');
const { sendPasswordResetEmail, sendPasswordChangeConfirmation } = require('../services/emailService');

// Request password reset
exports.requestPasswordReset = async (req, res) => {
  try {
    const { email } = req.body;

    // Validate email
    if (!email || !validator.isEmail(email)) {
      return res.status(400).json({
        error: 'Please provide a valid email address',
      });
    }

    // Find user (but don't reveal if user exists)
    const user = await User.findOne({ email: email.toLowerCase() });
    
    // Always return success to prevent email enumeration
    if (!user) {
      return res.status(200).json({
        message: 'If an account with that email exists, a password reset link has been sent.',
      });
    }

    // Delete any existing reset tokens for this user
    await PasswordReset.deleteMany({ userId: user._id });

    // Generate secure random token
    const resetToken = crypto.randomBytes(32).toString('hex');

    // Hash token before saving to database
    const hashedToken = await bcrypt.hash(resetToken, 10);

    // Save hashed token to database
    await PasswordReset.create({
      userId: user._id,
      token: hashedToken,
    });

    // Send reset email with original (unhashed) token
    await sendPasswordResetEmail(user.email, user.name, resetToken);

    res.status(200).json({
      message: 'If an account with that email exists, a password reset link has been sent.',
    });

  } catch (error) {
    console.error('Error in requestPasswordReset:', error);
    res.status(500).json({
      error: 'An error occurred. Please try again later.',
    });
  }
};

// Verify reset token
exports.verifyResetToken = async (req, res) => {
  try {
    const { token } = req.params;

    if (!token) {
      return res.status(400).json({
        error: 'Reset token is required',
      });
    }

    // Find all reset tokens and check each one
    const resetTokens = await PasswordReset.find().populate('userId');

    let validToken = null;
    for (const resetToken of resetTokens) {
      const isValid = await bcrypt.compare(token, resetToken.token);
      if (isValid) {
        validToken = resetToken;
        break;
      }
    }

    if (!validToken) {
      return res.status(400).json({
        error: 'Invalid or expired reset token',
      });
    }

    res.status(200).json({
      message: 'Token is valid',
      email: validToken.userId.email,
    });

  } catch (error) {
    console.error('Error in verifyResetToken:', error);
    res.status(500).json({
      error: 'An error occurred. Please try again later.',
    });
  }
};

// Reset password
exports.resetPassword = async (req, res) => {
  try {
    const { token } = req.params;
    const { password } = req.body;

    // Validate inputs
    if (!token) {
      return res.status(400).json({
        error: 'Reset token is required',
      });
    }

    if (!password || password.length < 8) {
      return res.status(400).json({
        error: 'Password must be at least 8 characters long',
      });
    }

    // Find valid reset token
    const resetTokens = await PasswordReset.find().populate('userId');

    let validToken = null;
    for (const resetToken of resetTokens) {
      const isValid = await bcrypt.compare(token, resetToken.token);
      if (isValid) {
        validToken = resetToken;
        break;
      }
    }

    if (!validToken) {
      return res.status(400).json({
        error: 'Invalid or expired reset token',
      });
    }

    // Update user password
    const user = validToken.userId;
    user.password = password;
    await user.save();

    // Delete used token
    await PasswordReset.deleteOne({ _id: validToken._id });

    // Send confirmation email
    await sendPasswordChangeConfirmation(user.email, user.name);

    res.status(200).json({
      message: 'Password has been reset successfully',
    });

  } catch (error) {
    console.error('Error in resetPassword:', error);
    res.status(500).json({
      error: 'An error occurred. Please try again later.',
    });
  }
};

The controller includes three functions:

  1. requestPasswordReset: Generates a token and sends the reset email
  2. verifyResetToken: Checks if a token is valid (useful for frontend validation)
  3. resetPassword: Updates the user’s password after token verification

Expert Note: We always return the same success message regardless of whether the email exists. This prevents attackers from using the reset feature to discover which email addresses have accounts (email enumeration attack).

Step 7: Set Up Routes

Now, let’s create the API endpoints to handle all password reset operations. For this, create a routes folder and add authRoutes.js. Then copy and paste the following snippet below:

const express = require('express');
const router = express.Router();
const authController = require('../controllers/authController');

// Request password reset
router.post('/forgot-password', authController.requestPasswordReset);

// Verify reset token
router.get('/reset-password/:token', authController.verifyResetToken);

// Reset password
router.post('/reset-password/:token', authController.resetPassword);

module.exports = router;

These routes handle all password reset operations. The token is passed as a URL parameter, which allows users to click the link in their email and land directly on the reset page.

Step 8: Create the Main Server File

Finally, let’s tie everything together in the main Express.js server file. To do so, create a server.js file in your project root. Then copy and paste the following snippets below:

require('dotenv').config();
const express = require('express');
const cors = require('cors');
const connectDB = require('./config/db');
const authRoutes = require('./routes/authRoutes');

const app = express();
const port = process.env.PORT || 5000;

// Connect to database
connectDB();

// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Routes
app.use('/api/auth', authRoutes);

// Health check endpoint
app.get('/health', (req, res) => {
  res.json({ status: 'Server is running' });
});

// Error handling middleware
app.use((error, req, res, next) => {
  console.error(error);
  res.status(500).json({ error: 'Internal server error' });
});

// Start server
app.listen(port, () => {
  console.log(`Server running on port ${port}`);
  console.log('Password reset API ready');
});

module.exports = app;

Step 9: Test Your Password Reset Flow

Now you’re ready to test the implementation. Start your development server:

npm run dev

You should see messages indicating the server and MongoDB connection are running.

To test the password reset flow, you can use tools like Postman or cURL. Here’s how to test each endpoint:

1. Request Password Reset: Open Postman and send a POST request to the password reset route.

http://localhost:5001/api/auth/forgot-password

In the request body, specify the email address of the user you want to reset their password.

{
  "email": "[email protected]"
}

Note: The user’s email needs to be present in the MongoDB database. Otherwise, you won’t receive the password reset email. I created a simple snippet for creating users in the database. Follow this link to access the file on GitHub.

Then click the Send button to send your request.

Testing Node.js password reset flow in Postman

You should receive a 200 success response.

Password reset request success response

After requesting a password reset, check your email inbox for the reset link. Click the link, and you’ll be able to set a new password.

Node.js password reset email example

This feature isn’t implemented in our current project as we’re interested in the backend flow. In a production application, the link should direct users to your frontend with a form that sends a POST request to the /api/auth/reset-password/reset-password endpoint.

For this tutorial, we can continue testing in Postman.

2. Verify Token: To verify the token, copy its value and then send a GET request to the following endpoint.

http://localhost:5001/api/auth/reset-password/TOKEN_FROM_EMAIL

You should see a response that reads “token is valid”.

Verify password reset token

The token will become invalid if its expiration time reaches.

Invalid token

Tip: If the token has expired, the user would need to generate a new one from the reset password endpoint.

3. Reset Password: Finally, change the request type to POST. Then update the request body with the new password.

{
  "password": "newSecurePassword123"
}
Node.js Password successfully changed

After updating your password, check your email inbox for the confirmation email.

Password changed successfully

Congratulations! You’ve successfully built a working password reset workflow for your Node.js application. You can access the full source code on GitHub.

Security Best Practices for Password Reset

When implementing password reset functionality, security should be your top priority. Here are essential security practices I recommend:

1. Use Cryptographically Secure Tokens

Always use crypto.randomBytes() for generating tokens. Never use predictable values like timestamps or sequential numbers. The tokens should be at least 32 bytes to prevent brute force attacks.

2. Hash Tokens Before Storage

Store hashed versions of tokens in your database using bcrypt. If your database is compromised, attackers won’t be able to use the tokens to reset passwords.

3. Implement Short Expiration Times

Reset tokens should expire quickly. Most people use between 15 to 60 minutes. Our implementation uses MongoDB’s TTL index to automatically delete expired tokens after 15 minutes. This reduces the attack window significantly.

4. Prevent Email Enumeration

Always return the same response message, whether or not the email exists in your system. This prevents attackers from discovering which email addresses have accounts.

5. Use HTTPS in Production

Always serve your application over HTTPS in production. This encrypts the reset token during transmission, preventing man-in-the-middle attacks.

Troubleshooting Common Issues

Email Not Received

If users aren’t receiving password reset emails, check these common issues:

  1. Verify SendLayer Configuration: Make sure your API key is correct, and your domain is authorized
  2. Check Spam Folder: Reset emails sometimes end up in spam
  3. Validate Email Address: Ensure the email format is correct
  4. Review SendLayer Logs: Log in to your SendLayer dashboard to check email delivery status

SendLayer provides detailed analytics that show email delivery status, opens, and clicks. This makes debugging email issues much easier compared to traditional SMTP.

Token Expiration Errors

If users report that reset links expire too quickly, you can adjust the expiration time in your PasswordReset model. Change the expires value from 900 (15 minutes) to your preferred duration in seconds.

MongoDB Connection Issues

If you see MongoDB connection errors, verify:

  • Your MongoDB service is running
  • The connection string in .env is correct
  • You have network access to MongoDB (especially important for MongoDB Atlas)
  • Your database user has the correct permissions

FAQs – Password Reset Node.js

Can I use this with a React or Vue frontend?

Absolutely! This backend works with any frontend framework like React, Vue, or Next.js. You’ll simply need to create forms that POST to these endpoints. Then handle the responses in your frontend. The reset token from the email URL can be extracted using your frontend router. Our tutorial on how to create a contact form in React should help you get started.

What’s the best way to send password reset emails in Node.js?

Use a dedicated transactional email service like SendLayer. It provides better deliverability, detailed analytics, and automatic retry mechanisms compared to self-hosted SMTP servers. SendLayer also protects your domain reputation by using subdomains for sending.

Should I hash password reset tokens in the database?

Yes, always hash tokens before storing them. If your database is compromised, attackers shouldn’t be able to use the tokens. Use bcrypt or crypto for hashing, just like we do with passwords.

That’s it! You’ve successfully implemented a secure password reset system in Node.js.

Your email content might be the reason your password reset emails end up in users’ Spam folders. Check out our tutorial on spam trigger words to avoid in password reset emails.

  • 200 Free Emails
  • Easy Setup
  • 5 Star Support

Ready to send your emails in the fastest and most reliable way? Get started today with the most user-friendly and powerful SMTP email delivery service. SendLayer Business includes 5,000 emails a month with premium support.