Add Auth and Email to a Product API

Secure a REST API with JWT authentication, route protection, and transactional email.

Introduction

30 Second Summary

Every app that stores user data needs a gatekeeper. Without authentication, anyone can create, edit, or delete anything. Your product catalog API is currently wide open.

In this project, you will add a complete authentication system to your existing product catalog API. You will build signup, login, logout, and password reset flows with secure tokens, input validation, rate limiting, and transactional emails.

What You'll Build

Register a new user in Postman, watch a welcome email land in your real inbox, then use your tokens to create products that only you can modify.

By the end of this project, you'll have:

  • A working signup and login system where you register with validated inputs, receive dual tokens (a short-lived access token and a long-lived refresh token), and see a welcome email arrive in your inbox.
  • A protected product catalog where only authenticated users can create products, and only the owner of a product can update or delete it. Unauthenticated requests get rejected with a 401, and attempts to modify someone else's product return a 403.
  • A password reset flow where you request a reset, receive an email with a time-limited token, and use it to set a new password.
  • Secret Mission: Add email verification so users must confirm their email address before they can log in.

Are there any prerequisites?

Yes. This project builds directly on top of the Product Catalog REST API you created with Express and Mongoose in Project 1. You need that existing codebase with a working MongoDB Atlas connection and Postman set up before starting.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters. Your product catalog API currently lets anyone create, update, or delete products with zero restrictions — this project closes that gap with a full authentication layer.

Install Dependencies and Set Up Project Structure

Your product catalog API from the first project works, but it has no concept of users or security. Anyone who knows the endpoints can create, modify, or delete any product.

To build authentication, you need packages for password hashing, token generation, rate limiting, input validation, and transactional email. You also need to set up the file structure that will house the entire auth system.

In this step, get ready to:
  • Install authentication and email packages with pinned versions.
  • Create a Resend account and configure environment variables.
  • Create the file structure for auth middleware and utilities.
Install the authentication packages

Your API needs five new packages to support authentication and email. Each one handles a distinct responsibility in the auth system you are about to build.

  • In the VS Code terminal, make sure you are inside your existing product catalog project directory.
  • Install all five packages with pinned versions by running this command:
npm install bcryptjs@3.0.3 jsonwebtoken@9.0.3 resend@6.18.0 express-rate-limit@8.6.1 express-validator@7.3.2

What do these packages do?

  • bcryptjs hashes passwords so they are never stored in plain text.
  • jsonwebtoken creates and verifies JWT access and refresh tokens.
  • resend sends transactional emails (welcome messages, password resets) via the Resend API.
  • express-rate-limit throttles repeated requests to prevent brute-force attacks.
  • express-validator validates and sanitizes incoming request data at the route level.
  • You should see output confirming all five packages were added to your package.json.

Seeing permission errors or EACCES?

Make sure you are running the terminal inside VS Code (not a system-level terminal that requires admin permissions). If you see network errors, check your internet connection and try again.

help me fix npm install errors in my project

Create a Resend account and configure environment variables

Resend is the email service your API will use to send welcome emails and password reset messages. The free tier includes 3,000 emails per month, which is more than enough for this project.

  • Open your browser and go to resend.com.
  • Click Sign Up and create a free account (you can sign up with GitHub or email).
  • Once logged in, click API Keys in the sidebar.
  • Click Create API Key.
  • Enter a name like Product Catalog and select Full access as the permission.
  • Copy the API key immediately and save it somewhere safe.

You can only view the key once

Resend shows the API key only at creation time. If you lose it, you will need to delete the key and create a new one.

Now add the new environment variables to your project.

  • Open your .env file in VS Code.
  • Add these five lines below your existing PORT variable:
JWT_SECRET=your-access-token-secret-change-this
JWT_REFRESH_SECRET=your-refresh-token-secret-change-this-too
ACCESS_TOKEN_EXPIRE=15m
REFRESH_TOKEN_EXPIRE=7d
RESEND_API_KEY=your-resend-api-key-here
  • Replace your-access-token-secret-change-this with any random string (this signs your access tokens).
  • Replace your-refresh-token-secret-change-this-too with a different random string (this signs your refresh tokens).
  • Replace your-resend-api-key-here with the API key you just copied from Resend.

What are these secrets for?

JWT_SECRET signs short-lived access tokens (15 minutes). JWT_REFRESH_SECRET signs long-lived refresh tokens (7 days).

Using two separate secrets means a compromised access token cannot forge a refresh token, and vice versa.

  • Save .env.

✔️ Awesome, I've got everything!

Great. Double check you have 7 lines total in your .env file (MONGO_URI, PORT, plus the 5 new ones).

ⓧ I'd like to double check the full code

Your .env file should look like this (with your own values substituted for the placeholders):

MONGO_URI=your-mongodb-atlas-connection-string-here
PORT=5000
JWT_SECRET=your-access-token-secret-change-this
JWT_REFRESH_SECRET=your-refresh-token-secret-change-this-too
ACCESS_TOKEN_EXPIRE=15m
REFRESH_TOKEN_EXPIRE=7d
RESEND_API_KEY=your-resend-api-key-here
Create the file structure for authentication

Your auth system needs new directories and files to keep code organized by responsibility. You will fill these files in the next steps.

  • Create the new directories and empty files by running these commands:

macOS

mkdir middleware utils
touch models/User.js controllers/authController.js routes/authRoutes.js middleware/auth.js middleware/rateLimiter.js middleware/validate.js utils/sendEmail.js

Windows

mkdir middleware, utils
New-Item models/User.js, controllers/authController.js, routes/authRoutes.js, middleware/auth.js, middleware/rateLimiter.js, middleware/validate.js, utils/sendEmail.js
  • Confirm the files exist by running:
ls middleware utils models controllers routes
  • You should see auth.js, rateLimiter.js, and validate.js inside middleware/.
  • You should see sendEmail.js inside utils/.
  • You should see User.js inside models/ alongside the existing Product.js.

Missing files or directories?

Make sure you ran the commands from the root of your project directory (the folder containing server.js).

On Windows, if New-Item fails, make sure you are using PowerShell (not Command Prompt).

help me create the middleware and utils directories for my auth project

Now verify that all five packages load correctly.

  • Before you run the next command, predict what you think will happen. Will all packages load successfully, or will one fail?
  • Verify all packages are installed by running:
node -e "require('bcryptjs'); require('jsonwebtoken'); require('express-rate-limit'); require('express-validator'); const {Resend} = require('resend'); console.log('All packages loaded')"

You should see All packages loaded printed in your terminal. If any package failed to install, the command would throw a MODULE_NOT_FOUND error instead.

Seeing MODULE_NOT_FOUND?

This means one of the packages did not install. Run npm install again from the folder containing your package.json. Check that the error names which module is missing and reinstall that specific package.

help me fix a MODULE_NOT_FOUND error when loading auth packages

Your project now has all the packages, secrets, and file scaffolding it needs. Next up, you will build the User model and the signup, login, logout, and refresh endpoints.

Build the User Model, Auth Endpoints, Validation, and Rate Limiting

Your product catalog API can store and serve products, but it has no idea who is making requests. Anyone can create, update, or delete products without proving their identity.

In this step, you will build a User model with secure password hashing, create middleware that rejects bad input and blocks brute-force attempts, and wire up signup, login, logout, and token-refresh endpoints. By the end, your API knows how to register users and hand out access tokens.

In this step, get ready to:
  • Build the User model with password hashing and a comparePassword method.
  • Create rate limiting and input validation middleware.
  • Build signup, login, logout, and refresh endpoints and test them in Postman.
Build the User model

Every authentication system starts with a user record. Your User model needs to store credentials securely and provide a way to verify passwords at login.

  • Open models/User.js in your editor and paste the schema definition:
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');

const userSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Please provide a name'],
    trim: true,
  },
  email: {
    type: String,
    required: [true, 'Please provide an email'],
    unique: true,
    lowercase: true,
    match: [/^\S+@\S+\.\S+$/, 'Please provide a valid email'],
  },
  password: {
    type: String,
    required: [true, 'Please provide a password'],
    minlength: 6,
    select: false,
  },
  refreshToken: {
    type: String,
    select: false,
  },
  resetPasswordToken: String,
  resetPasswordExpire: Date,
  createdAt: {
    type: Date,
    default: Date.now,
  },
});

What does this schema do?

Each field defines a piece of user data Mongoose will store in MongoDB. The email field uses a regex match to reject invalid formats at the database level.

The select: false option on password and refreshToken means Mongoose excludes them from query results by default. You must explicitly use .select('+password') when you need them, which prevents accidentally leaking sensitive fields.

  • Below the schema closing });, add the pre-save hook and comparePassword method:
// Hash password before saving to the database
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) return next();
  const salt = await bcrypt.genSalt(10);
  this.password = await bcrypt.hash(this.password, salt);
  next();
});

// Compare entered password with the stored hash
userSchema.methods.comparePassword = async function (candidatePassword) {
  return await bcrypt.compare(candidatePassword, this.password);
};

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

What does this code do?

The pre('save') hook runs automatically before any user document is saved. It checks isModified('password') so it only re-hashes when the password actually changes, not on every save.

The comparePassword instance method uses bcrypt.compare to check a plain-text candidate against the stored hash. This is how login will verify credentials without ever storing the original password.

  • Save models/User.js.
  • Restart your server by pressing Ctrl+C in the terminal, then running:
node server.js

You should see MongoDB connected and Server running on port 5000 with no errors about the User model.

Seeing a syntax error on startup?

Check that the closing }); of the schema appears before your pre('save') hook. A missing bracket or comma after the last field in the schema is the most common cause.

help me debug a syntax error in my User model

Create rate limiting and validation middleware

Before building the auth endpoints themselves, you need two gatekeepers: a rate limiter to block brute-force login attempts, and a validator to reject malformed requests before they reach your controller.

  • Open middleware/rateLimiter.js and paste the rate limiter configuration:
const rateLimit = require('express-rate-limit');

// Limit auth endpoints to 5 requests per 15-minute window per IP
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  message: { message: 'Too many requests, please try again after 15 minutes' },
  standardHeaders: true,
  legacyHeaders: false,
});

module.exports = { authLimiter };

What does this code do?

The authLimiter middleware tracks requests per IP address. If the same IP hits a rate-limited route more than 5 times within 15 minutes, it receives a 429 status with the Too many requests message.

Setting standardHeaders: true sends RateLimit-* headers so clients know how many attempts remain. Setting legacyHeaders: false disables the older X-RateLimit-* format.

  • Save middleware/rateLimiter.js.
  • Now open middleware/validate.js and paste the validation chains:
const { body, validationResult } = require('express-validator');

// Check for validation errors and return 400 if any exist
const handleValidationErrors = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  next();
};

// Validation rules for the signup endpoint
const validateSignup = [
  body('name').trim().notEmpty().withMessage('Name is required'),
  body('email').isEmail().withMessage('Please provide a valid email'),
  body('password')
    .isLength({ min: 6 })
    .withMessage('Password must be at least 6 characters'),
  handleValidationErrors,
];

// Validation rules for the login endpoint
const validateLogin = [
  body('email').isEmail().withMessage('Please provide a valid email'),
  body('password').notEmpty().withMessage('Password is required'),
  handleValidationErrors,
];

module.exports = { validateSignup, validateLogin };

What does this code do?

Each validation array is a chain of Express middleware. When a request hits a route using validateSignup, each body() call checks one field and attaches an error if it fails.

The handleValidationErrors middleware runs last in each array. It collects all accumulated errors and returns them as a 400 response. If everything passes, it calls next() to reach the controller.

  • Save middleware/validate.js.
  • Restart your server to confirm both middleware files load without errors:
node server.js

You should still see MongoDB connected and Server running on port 5000 with no new errors.

Seeing a require error for express-validator?

Make sure you installed express-validator in Step 1. Run npm list express-validator to confirm it appears in your dependencies. If not, run npm install express-validator again.

help me fix a missing module error for express-validator or express-rate-limit

Build the auth controller and wire up routes

Now for the core logic. Your auth controller will handle user registration, login, logout, and token refresh. Each function generates a short-lived access token and a long-lived refresh token.

  • Open controllers/authController.js and paste the imports and token helper functions:
const jwt = require('jsonwebtoken');
const crypto = require('crypto');
const User = require('../models/User');
const sendEmail = require('../utils/sendEmail');

// Generate a short-lived access token (15 min)
const generateAccessToken = (id) => {
  return jwt.sign({ id }, process.env.JWT_SECRET, {
    expiresIn: process.env.ACCESS_TOKEN_EXPIRE || '15m',
  });
};

// Generate a long-lived refresh token (7 days)
const generateRefreshToken = (id) => {
  return jwt.sign({ id }, process.env.JWT_REFRESH_SECRET, {
    expiresIn: process.env.REFRESH_TOKEN_EXPIRE || '7d',
  });
};

What are access tokens and refresh tokens?

An access token is a short-lived credential (15 minutes) that proves identity on each request. It expires quickly so that if stolen, the damage window is small.

A refresh token lives longer (7 days) and its only job is to request a new access token without re-entering credentials. You store it on the user document so you can invalidate it on logout.

  • Below the helper functions, paste the signup function:
// Register a new user and return tokens
const signup = async (req, res) => {
  try {
    const { name, email, password } = req.body;

    const existingUser = await User.findOne({ email });
    if (existingUser) {
      return res.status(400).json({ message: 'User already exists' });
    }

    const user = await User.create({ name, email, password });

    const accessToken = generateAccessToken(user._id);
    const refreshToken = generateRefreshToken(user._id);

    user.refreshToken = refreshToken;
    await user.save();

    res.status(201).json({
      message: 'User created successfully',
      accessToken,
      refreshToken,
      user: { id: user._id, name: user.name, email: user.email },
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

What does signup do?

It checks for an existing user with that email, then calls User.create() which triggers the pre-save hook to hash the password automatically.

After creation, it generates both tokens and stores the refresh token on the user document. The response includes both tokens so the client can immediately make authenticated requests.

  • Below the signup function, paste the login function:
// Authenticate user and return tokens
const login = async (req, res) => {
  try {
    const { email, password } = req.body;

    const user = await User.findOne({ email }).select('+password');
    if (!user) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }

    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }

    const accessToken = generateAccessToken(user._id);
    const refreshToken = generateRefreshToken(user._id);

    user.refreshToken = refreshToken;
    await user.save();

    res.status(200).json({
      message: 'Login successful',
      accessToken,
      refreshToken,
      user: { id: user._id, name: user.name, email: user.email },
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

Why does login use .select('+password')?

Remember that the User schema sets select: false on the password field. The .select('+password') override tells Mongoose to include it just for this query so comparePassword can do its job.

Both the !user and !isMatch cases return the same generic message. This prevents attackers from learning whether an email exists in your system.

  • Below the login function, paste the logout function:
// Invalidate refresh token on logout
const logout = async (req, res) => {
  try {
    const user = await User.findById(req.user._id).select('+refreshToken');
    if (user) {
      user.refreshToken = undefined;
      await user.save();
    }

    res.status(200).json({ message: 'Logged out successfully' });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

What does logout do?

It clears the refresh token stored on the user document. Even if someone has the old refresh token, they cannot use it to get new access tokens. The existing access token stays valid until its 15-minute expiry (stateless by design), but without a refresh token the session ends there.

  • Below logout, paste the refreshAccessToken function and the module exports:
// Verify refresh token and issue a new token pair
const refreshAccessToken = async (req, res) => {
  try {
    const { refreshToken } = req.body;

    if (!refreshToken) {
      return res.status(400).json({ message: 'Refresh token is required' });
    }

    let decoded;
    try {
      decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    } catch (err) {
      return res.status(401).json({ message: 'Invalid or expired refresh token' });
    }

    const user = await User.findById(decoded.id).select('+refreshToken');
    if (!user || user.refreshToken !== refreshToken) {
      return res.status(401).json({ message: 'Invalid refresh token' });
    }

    const newAccessToken = generateAccessToken(user._id);
    const newRefreshToken = generateRefreshToken(user._id);

    user.refreshToken = newRefreshToken;
    await user.save();

    res.status(200).json({
      accessToken: newAccessToken,
      refreshToken: newRefreshToken,
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

module.exports = { signup, login, logout, refreshAccessToken };

What does refreshAccessToken do?

It verifies the incoming refresh token with jwt.verify, then checks that the token matches what is stored on the user document. This double check means a stolen refresh token that has already been rotated out will be rejected.

On success it returns a fresh pair of tokens and updates the stored refresh token. This is called token rotation and limits the window an attacker has if they intercept a single refresh token.

  • Save controllers/authController.js.
  • Now open routes/authRoutes.js and paste the route definitions that wire together your middleware and controller:
const express = require('express');
const {
  signup,
  login,
  logout,
  refreshAccessToken,
} = require('../controllers/authController');
const { validateSignup, validateLogin } = require('../middleware/validate');
const { authLimiter } = require('../middleware/rateLimiter');
const { protect } = require('../middleware/auth');

const router = express.Router();

// Rate limit + validate before hitting controller
router.post('/signup', authLimiter, validateSignup, signup);
router.post('/login', authLimiter, validateLogin, login);
router.post('/logout', protect, logout);
router.post('/refresh-token', refreshAccessToken);

module.exports = router;

What does the middleware order mean?

Each route lists middleware left-to-right. For /signup, the request hits the rate limiter first, then the input validator, and only reaches the controller if both pass.

The /logout route uses protect (which you will build in the next step) to ensure only authenticated users can log out. The /refresh-token route needs no auth middleware because the refresh token in the body IS the proof.

  • Save routes/authRoutes.js.
  • Open server.js and find this line:
const productRoutes = require('./routes/productRoutes');
  • Add the auth routes import directly below it:
const authRoutes = require('./routes/authRoutes');
  • Now find this line:
app.use('/api/products', productRoutes);
  • Add the auth routes registration directly below it:
app.use('/api/auth', authRoutes);
  • Save server.js.
  • Restart your server:
node server.js

You should see MongoDB connected and Server running on port 5000 with no errors. Your auth routes are now live.

Before you test in Postman, what do you think will happen if you send a signup request with an invalid email?

  • Open Postman and create a new POST request to:
http://localhost:5000/api/auth/signup
  • Set the request body to raw JSON with an invalid email:
{
  "name": "Test User",
  "email": "notanemail",
  "password": "test123"
}
  • Click Send.

You get a 400 response with an errors array containing the message Please provide a valid email. Your validation middleware caught the bad input before it ever reached the controller.

  • Now fix the email and send a valid signup request:
{
  "name": "Test User",
  "email": "test@example.com",
  "password": "test123"
}
  • Click Send.

You get a 201 response with accessToken, refreshToken, and a user object containing your name and email. Your signup flow works end to end.

  • Now test rate limiting. Create a new POST request to http://localhost:5000/api/auth/login with any body:
{
  "email": "test@example.com",
  "password": "wrongpassword"
}
  • Click Send rapidly 6 times in a row.

On the 6th request you get a 429 status with Too many requests, please try again after 15 minutes. Rate limiting is working.

Not getting a 429 after 6 requests?

The rate limiter counts per IP. If you restarted the server between attempts, the in-memory counter resets. Send all 6 requests without restarting.

Also confirm authLimiter is applied to the /login route in routes/authRoutes.js. If the route reads router.post('/login', validateLogin, login) without authLimiter in the chain, rate limiting is not active.

help me debug why rate limiting isn't triggering on my auth endpoints

✔️ Awesome, I've got everything!

Great. Double check you have saved all your files: models/User.js, middleware/rateLimiter.js, middleware/validate.js, controllers/authController.js, routes/authRoutes.js, and server.js.

ⓧ I'd like to double check the full code

Scroll up and confirm each code chunk is present in your files, in order:

  • models/User.js: schema definition (fields) followed by the pre-save hook, comparePassword method, and the module.exports line.
  • middleware/rateLimiter.js: the authLimiter configuration and export.
  • middleware/validate.js: handleValidationErrors, validateSignup, validateLogin, and the export.
  • controllers/authController.js: imports, generateAccessToken, generateRefreshToken, signup, login, logout, refreshAccessToken, and the export.
  • routes/authRoutes.js: all four routes with middleware applied.
  • server.js: authRoutes imported and registered at /api/auth.

Your API now has a working user registration system with input validation and brute-force protection. Next up, you will build the auth middleware that protects your product routes so only logged-in users can create, update, or delete products.

Protect Product Routes with Auth Middleware and Ownership

Your signup, login, and token refresh endpoints are working. But there is a massive problem: the product catalog routes are still completely unprotected.

Anyone can create, update, or delete products without logging in. Even once you add authentication, any logged-in user could modify products that belong to someone else. In this step, you will build middleware that verifies tokens and enforces ownership so only the right people can touch the right products.

In this step, get ready to:
  • Build auth middleware that extracts and verifies Bearer tokens.
  • Link every product to the user who created it.
  • Protect write routes while keeping read routes public.
Prove the API is wide open

Before writing any fix, prove the problem exists. You will send a request to create a product with no credentials at all.

Before you run this, what do you think will happen when you POST to the products endpoint without any authentication?

  • In Postman, create a new POST request to http://localhost:5000/api/products.
  • In the Body tab, select raw and choose JSON from the dropdown.
  • Paste this JSON body:
{
  "name": "Test Widget",
  "description": "A test product",
  "price": 9.99,
  "category": "Testing"
}
  • Make sure the Authorization tab is set to No Auth.
  • Click Send.

You get a 201 response with the created product. No token, no credentials, no problem. That is exactly the gap you are about to close.

Why does this matter?

In a production API, unauthenticated write access means anyone on the internet could flood your database with fake products or delete real ones. Authentication ensures only verified users can make changes.

Build the auth middleware

The protect middleware will run before any protected route handler. It checks the incoming request for a valid JWT in the Authorization header.

  • Open middleware/auth.js and add the following code:
const jwt = require('jsonwebtoken');
const User = require('../models/User');

// Middleware that checks for a valid access token
const protect = async (req, res, next) => {
  let token;

  if (
    req.headers.authorization &&
    req.headers.authorization.startsWith('Bearer')
  ) {
    token = req.headers.authorization.split(' ')[1];
  }

  if (!token) {
    return res.status(401).json({ message: 'Not authorized, no token provided' });
  }

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = await User.findById(decoded.id);
    next();
  } catch (error) {
    return res.status(401).json({ message: 'Not authorized, token invalid or expired' });
  }
};

module.exports = { protect };

What does this code do?

  • It looks for an Authorization header that starts with Bearer and extracts the token after the space.
  • If no token is found, it immediately returns a 401 response.
  • It verifies the token against your JWT_SECRET environment variable. If valid, it looks up the user by the ID stored in the token and attaches them to req.user.
  • Calling next() passes control to the actual route handler only if the token checks out.
  • Save middleware/auth.js.

This middleware takes effect once you apply it to the product routes in the next substep.

Add ownership and protect the routes

Now you will wire the protect middleware into product routes, add a createdBy field to the Product model, and update the controller to enforce ownership.

  • Open routes/productRoutes.js and replace its contents with:
const express = require('express');
const {
  getProducts,
  getProduct,
  createProduct,
  updateProduct,
  deleteProduct,
} = require('../controllers/productController');
const { protect } = require('../middleware/auth');

const router = express.Router();

// GET routes remain public
router.get('/', getProducts);
router.get('/:id', getProduct);

// Write routes require authentication
router.post('/', protect, createProduct);
router.put('/:id', protect, updateProduct);
router.delete('/:id', protect, deleteProduct);

module.exports = router;

What changed here?

The protect middleware is imported from middleware/auth.js and placed before the handler on POST, PUT, and DELETE routes. GET routes stay public so anyone can browse products without logging in.

  • Save routes/productRoutes.js.
  • Return to Postman and send the same POST request to http://localhost:5000/api/products (still with No Auth).

You now get a 401 response with the message "Not authorized, no token provided". The middleware is blocking unauthenticated requests.

Still getting 201 instead of 401?

  • Make sure you saved both middleware/auth.js and routes/productRoutes.js.
  • Restart your server if it did not auto-reload.
  • Check that the Postman Authorization tab is set to No Auth (not inheriting a token from a collection).

help me debug why my protect middleware is not blocking unauthenticated requests

Next, update the Product model to track who created each product. Open models/Product.js and replace its contents with:

const mongoose = require('mongoose');

const productSchema = new mongoose.Schema({
  name: {
    type: String,
    required: [true, 'Please provide a product name'],
    trim: true,
  },
  description: {
    type: String,
    required: [true, 'Please provide a description'],
  },
  price: {
    type: Number,
    required: [true, 'Please provide a price'],
    min: 0,
  },
  category: {
    type: String,
    required: [true, 'Please provide a category'],
    trim: true,
  },
  // Links this product to the user who created it
  createdBy: {
    type: mongoose.Schema.Types.ObjectId,
    ref: 'User',
    required: true,
  },
  createdAt: {
    type: Date,
    default: Date.now,
  },
});

module.exports = mongoose.model('Product', productSchema);

What is createdBy?

The createdBy field stores the ObjectId of the User who created the product. The ref: 'User' tells Mongoose this ID points to the User collection, enabling .populate() to fill in the user's details automatically.

  • Save models/Product.js.

Now update the product controller. The createProduct function needs to record who created the product, and updateProduct and deleteProduct need to verify ownership before allowing changes.

  • Open controllers/productController.js and replace the entire file with the following. Start with the imports and read functions:
const Product = require('../models/Product');

// Public: anyone can browse products
const getProducts = async (req, res) => {
  try {
    const products = await Product.find().populate('createdBy', 'name email');
    res.status(200).json(products);
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

const getProduct = async (req, res) => {
  try {
    const product = await Product.findById(req.params.id).populate('createdBy', 'name email');
    if (!product) {
      return res.status(404).json({ message: 'Product not found' });
    }
    res.status(200).json(product);
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

What does populate do?

The .populate('createdBy', 'name email') call replaces the raw ObjectId in createdBy with the actual user's name and email. This means GET responses show who created each product without a second query.

  • Continue below the read functions with the create function:
// Authenticated: links product to the logged-in user
const createProduct = async (req, res) => {
  try {
    const { name, description, price, category } = req.body;

    const product = await Product.create({
      name,
      description,
      price,
      category,
      createdBy: req.user._id,
    });

    res.status(201).json(product);
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

What changed in createProduct?

The createdBy: req.user._id line automatically stamps each new product with the authenticated user's ID. Because the protect middleware runs first, req.user is guaranteed to exist by the time this function executes.

  • Continue with the update and delete functions that enforce ownership:
// Ownership check: only the creator can update
const updateProduct = async (req, res) => {
  try {
    const product = await Product.findById(req.params.id);

    if (!product) {
      return res.status(404).json({ message: 'Product not found' });
    }

    if (product.createdBy.toString() !== req.user._id.toString()) {
      return res.status(403).json({ message: 'Not authorized to modify this product' });
    }

    const updatedProduct = await Product.findByIdAndUpdate(
      req.params.id,
      req.body,
      { new: true, runValidators: true }
    );

    res.status(200).json(updatedProduct);
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};
// Ownership check: only the creator can delete
const deleteProduct = async (req, res) => {
  try {
    const product = await Product.findById(req.params.id);

    if (!product) {
      return res.status(404).json({ message: 'Product not found' });
    }

    if (product.createdBy.toString() !== req.user._id.toString()) {
      return res.status(403).json({ message: 'Not authorized to modify this product' });
    }

    await Product.findByIdAndDelete(req.params.id);

    res.status(200).json({ message: 'Product deleted successfully' });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

module.exports = { getProducts, getProduct, createProduct, updateProduct, deleteProduct };

How does the ownership check work?

Both updateProduct and deleteProduct first look up the product, then compare product.createdBy to req.user._id. Both values are ObjectIds, so they are converted to strings with .toString() before comparison.

If the IDs do not match, the request is rejected with a 403 Forbidden. This means a logged-in user can only modify their own products.

  • Save controllers/productController.js.

Time to test the full flow. What do you expect to happen when you POST a product with a valid token now?

  • In Postman, first log in to get a fresh token. Send a POST request to http://localhost:5000/api/auth/login with your user's email and password.
  • Copy the accessToken value from the response.
  • Return to your POST http://localhost:5000/api/products request.
  • Click the Authorization tab.
  • Select Bearer Token from the Auth Type dropdown.
  • Paste your access token into the Token field.
  • Click Send.

You get a 201 response. The product JSON now includes a createdBy field containing your user ID.

  • Now test ownership. Sign up a second user with a different email, then log in as that second user to get their access token.
  • Using the second user's token, send a PUT request to http://localhost:5000/api/products/<product-id> where <product-id> is the ID of the product you just created with the first user.

You get a 403 response with "Not authorized to modify this product". Only the product's creator can change it.

Getting 500 instead of 403?

  • Make sure the product ID in your URL is a valid MongoDB ObjectId from the 201 response.
  • If you see an error about createdBy being required, delete any old test products from your database that lack this field (they were created before the model update).
  • Confirm both models/Product.js and controllers/productController.js are saved.

help me debug why I am getting 500 errors on product update

✔️ Awesome, I've got everything!

Great. Double check you have saved all four files.

ⓧ I'd like to double check the full code

Scroll up and confirm each of the following code blocks is present in order in your project:

  • middleware/auth.js with the full protect function.
  • routes/productRoutes.js with protect applied to POST, PUT, and DELETE.
  • models/Product.js with the createdBy field.
  • controllers/productController.js with populate on GETs, createdBy on create, and ownership checks on update and delete.

Your API now rejects unauthenticated writes and prevents users from tampering with each other's products. Next up, you will add transactional emails so users get a welcome message the moment they sign up.

Send Welcome Email on Signup

Your authentication system works, but users get zero feedback after signing up. They fill out a form, get a token back, and have no confirmation the system actually registered them.

In production apps, a welcome email builds trust and confirms the email address is reachable. In this step, you will wire up Resend to send a welcome email every time a new user signs up.

In this step, get ready to:
  • Build a reusable email utility that sends emails through Resend.
  • Update the signup function to send a welcome email after user creation.
  • Verify the welcome email arrives in your inbox via Postman.
Build the email utility

Your project already has an empty utils/sendEmail.js file from the earlier setup. Now you will fill it with a reusable function that any part of your app can call to send an email.

  • Open utils/sendEmail.js in your editor and add the following code:
const { Resend } = require('resend');

// Initialize the Resend client with your API key
const resend = new Resend(process.env.RESEND_API_KEY);

// Reusable email sender accepting to, subject, and html
const sendEmail = async (options) => {
  const { data, error } = await resend.emails.send({
    from: 'Product Catalog <onboarding@resend.dev>',
    to: options.to,
    subject: options.subject,
    html: options.html,
  });

  if (error) {
    throw new Error(`Email failed: ${error.message}`);
  }

  return data;
};

module.exports = sendEmail;

What does this code do?

  • It creates a Resend client using the RESEND_API_KEY from your .env file.
  • The from field uses onboarding@resend.dev, a Resend-provided test sender that works without domain verification.
  • If Resend returns an error, the function throws so the caller can decide how to handle it.
  • Save utils/sendEmail.js.

Why onboarding@resend.dev?

Resend provides this test sender so you can send emails immediately without setting up or verifying your own domain. Emails from this address will arrive in your inbox normally.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

ⓧ I'd like to double check the full code

const { Resend } = require('resend');

const resend = new Resend(process.env.RESEND_API_KEY);

const sendEmail = async (options) => {
  const { data, error } = await resend.emails.send({
    from: 'Product Catalog <onboarding@resend.dev>',
    to: options.to,
    subject: options.subject,
    html: options.html,
  });

  if (error) {
    throw new Error(`Email failed: ${error.message}`);
  }

  return data;
};

module.exports = sendEmail;
Add the welcome email to signup

The email utility is ready. Now you need to call it from the signup function so every new user receives a welcome message.

  • In controllers/authController.js, find the signup function.
  • Locate the line await user.save(); (where the refresh token is persisted).
  • Add the following code directly below that line, before the res.status(201) response:
    // Send welcome email (non-blocking: failure won't break signup)
    try {
      await sendEmail({
        to: user.email,
        subject: 'Welcome to Product Catalog!',
        html: `<h1>Welcome, ${user.name}!</h1><p>Thanks for signing up. You can now manage products in the catalog.</p>`,
      });
    } catch (emailError) {
      console.error('Welcome email failed:', emailError.message);
    }

Why wrap the email in its own try/catch?

If Resend is down or the API key is wrong, the email call will throw. Without this inner try/catch, that error would bubble up to the outer catch and return a 500 to the user, even though their account was created successfully.

By catching email errors separately, signup still returns 201 with valid tokens. The failure is logged to the console so you can investigate, but the user is never blocked.

  • Save controllers/authController.js.

Seeing a require error on startup?

  • The import const sendEmail = require('../utils/sendEmail'); already exists at the top of your auth controller from the earlier step. Confirm it is still there.
  • Make sure you saved utils/sendEmail.js with the code from the previous substep. An empty file causes a module.exports is not a function error.

Help me debug a require error in my authController.

Test the welcome email in Postman

Time to see the email arrive. You will sign up a brand new user with your real email address and check your inbox.

  • Restart your server so the new code loads. In your terminal, press Ctrl+C to stop it, then run:
node server.js
  • Switch to Postman.
  • Create a new POST request to http://localhost:5000/api/auth/signup.
  • Set the Body to raw and select JSON from the dropdown.
  • Enter the following JSON, replacing the email with your real email address:
{
  "name": "Test User",
  "email": "[[YOUR_EMAIL=\"your real email address\"]]",
  "password": "password123"
}

Before you hit Send, what do you think the response will contain? Will the email arrive instantly or after the response?

  • Click Send.

You should see a 201 response with your access token, refresh token, and user object. The email is sent during the request, so by the time you see the response, the email is already on its way.

  • Check your email inbox. You should see a welcome email with the subject Welcome to Product Catalog! and a greeting that includes your user's name.

Check the Resend dashboard instead

If the email is not in your inbox yet, open your Resend dashboard at resend.com/emails. Every email you send appears there with its delivery status, even if it has not landed in your inbox yet.

🙋‍♀️ Got a 201 but no email?

  • Check your terminal for Welcome email failed: followed by an error message. This means the try/catch caught an issue.
  • Confirm your RESEND_API_KEY in .env is correct. An invalid key causes a silent failure since the try/catch swallows it.
  • Check your spam folder. Emails from onboarding@resend.dev occasionally land there on first send.

Help me debug why my welcome email is not arriving.

Your API now welcomes every new user with a real email. Next up, you will build a password reset flow so users who forget their credentials can recover their account through email.

Implement Password Reset via Email

Your authentication system is working and welcome emails are landing in inboxes. But what happens when a user forgets their password?

Without a reset flow, they are locked out permanently. In this step, you will build the full password reset cycle: request a reset, receive an email with a secure token, and use that token to set a new password.

In this step, get ready to:
  • Build the forgotPassword controller that generates a secure reset token and emails it to the user.
  • Build the resetPassword controller that validates the token and sets a new password.
  • Wire up the new routes and test the full reset cycle in Postman.
Build the forgotPassword controller

The forgot-password flow works in two stages. First, the user requests a reset by providing their email. The server generates a random token, stores a hashed version in the database, and emails the plain token to the user.

Why hash the reset token before storing it?

The plain token is sent to the user's email. Only the SHA-256 hash is stored in the database. If the database is compromised, an attacker cannot use the stored hash to reset passwords.

This is the same principle behind password hashing. Never store secrets in a form that can be directly used.

  • In controllers/authController.js, find the closing line of the refreshAccessToken function (the }; after its final catch block).
  • Add the following forgotPassword function below it:
const forgotPassword = async (req, res) => {
  try {
    const { email } = req.body;

    // Check if a user exists with this email
    const user = await User.findOne({ email });
    if (!user) return res.status(404).json({ message: 'No user found with that email' });

    // Generate a random token and hash it for database storage
    const resetToken = crypto.randomBytes(32).toString('hex');
    user.resetPasswordToken = crypto.createHash('sha256').update(resetToken).digest('hex');
    user.resetPasswordExpire = Date.now() + 10 * 60 * 1000;
    await user.save();

    // Build the reset URL and send the plain token via email
    const resetUrl = `${req.protocol}://${req.get('host')}/api/auth/reset-password/${resetToken}`;
    await sendEmail({ to: user.email, subject: 'Password Reset Request', html: `<h1>Password Reset</h1><p>You requested a password reset.</p><p>Your reset token: <strong>${resetToken}</strong></p><p>Make a PUT request to: ${resetUrl}</p><p>This token expires in 10 minutes.</p>` });

    res.status(200).json({ message: 'Password reset email sent' });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

What does this code do?

  • crypto.randomBytes(32).toString('hex') generates a cryptographically secure 64-character hex string. This plain token is what the user receives in their email.
  • crypto.createHash('sha256').update(resetToken).digest('hex') hashes the token before storing it. The database only ever sees the hash, not the raw token.
  • The expiry is set to 10 minutes from now (Date.now() + 10 * 60 * 1000). After that, the token becomes unusable.
  • Save controllers/authController.js.
Build the resetPassword controller

The second half of the flow handles the token the user received. It re-hashes the plain token from the URL, finds the matching user, and lets them set a new password.

  • In controllers/authController.js, add the following resetPassword function below forgotPassword:
const resetPassword = async (req, res) => {
  try {
    // Hash the token from the URL to compare with stored hash
    const hashedToken = crypto.createHash('sha256').update(req.params.token).digest('hex');

    // Find user with matching token that hasn't expired
    const user = await User.findOne({ resetPasswordToken: hashedToken, resetPasswordExpire: { $gt: Date.now() } });
    if (!user) return res.status(400).json({ message: 'Invalid or expired token' });

    // Set the new password and clear reset fields
    const { password } = req.body;
    if (!password) return res.status(400).json({ message: 'Please provide a new password' });
    user.password = password;
    user.resetPasswordToken = undefined;
    user.resetPasswordExpire = undefined;

    // Generate fresh tokens so the user is logged in immediately
    const accessToken = generateAccessToken(user._id);
    const refreshToken = generateRefreshToken(user._id);
    user.refreshToken = refreshToken;
    await user.save();

    res.status(200).json({ message: 'Password reset successful', accessToken, refreshToken });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

What does this code do?

  • It re-hashes the plain token from the URL parameter using the same SHA-256 algorithm. This produces the same hash that forgotPassword stored in the database.
  • The query checks both the token match AND that resetPasswordExpire is still in the future ($gt: Date.now()). An expired token is rejected.
  • After setting the new password, it clears both reset fields so the token cannot be reused. The pre-save hook on the User model automatically hashes the new password.
  • Fresh access and refresh tokens are returned so the user is immediately logged in after resetting.

Now update the module.exports at the bottom of the file to include both new functions.

  • Find this line at the bottom of controllers/authController.js:
module.exports = { signup, login, logout, refreshAccessToken };
  • Replace it with:
module.exports = { signup, login, logout, refreshAccessToken, forgotPassword, resetPassword };
  • Save controllers/authController.js.
  • Restart your server to confirm there are no syntax errors by running:
node server.js
  • You should see Server running on port 5000 and MongoDB connected with no errors.

Seeing a syntax error on startup?

  • Check that you placed the forgotPassword and resetPassword functions AFTER the closing }; of refreshAccessToken and BEFORE the module.exports line.
  • Make sure every template literal uses backticks (`) not regular quotes.
  • Confirm the module.exports line includes all six function names separated by commas.

Why is my server crashing with a syntax error in authController.js?

Add the password reset routes

The controller functions exist, but no URL maps to them yet. You need two new routes: one to request a reset, and one to consume the token.

  • Open routes/authRoutes.js.
  • Find the import line at the top:
const { signup, login, logout, refreshAccessToken } = require('../controllers/authController');
  • Replace it with:
const { signup, login, logout, refreshAccessToken, forgotPassword, resetPassword } = require('../controllers/authController');
  • Find the router.post('/refresh-token', refreshAccessToken); line.
  • Add these two routes below it:
// Password reset routes
router.post('/forgot-password', authLimiter, forgotPassword);
router.put('/reset-password/:token', resetPassword);

What do these routes do?

  • POST /forgot-password uses authLimiter to prevent attackers from spamming reset emails to a victim's inbox.
  • PUT /reset-password/:token has no rate limiter because the token itself is the proof of access. The 10-minute expiry limits abuse.
  • Save routes/authRoutes.js.
  • Restart your server by pressing Ctrl+C and running:
node server.js
  • Confirm you see Server running on port 5000 and MongoDB connected.

Server won't start after adding routes?

  • Check that forgotPassword and resetPassword appear in both the require destructure at the top of authRoutes.js AND in the module.exports of authController.js. A mismatch causes an undefined route handler error.

Why is my server crashing after adding the password reset routes?

Test the full password reset flow

Before you run this test, what do you expect will happen when you POST to /api/auth/forgot-password with a registered email? Think about what the server does and what arrives in your inbox.

  • Switch back to Postman.
  • Create a new request: POST to http://localhost:5000/api/auth/forgot-password.
  • In the Body tab, select raw and JSON, then enter the email of a user you registered earlier:
{
  "email": "your-registered-email@example.com"
}
  • Click Send.

You should see a 200 response with the message Password reset email sent.

  • Check your inbox (or the Resend dashboard at resend.com/emails) for the reset email.
  • Copy the reset token from the email body.

Now use that token to set a new password. What do you expect the response will contain?

  • Create a new request: PUT to http://localhost:5000/api/auth/reset-password/YOUR_TOKEN_HERE (replace YOUR_TOKEN_HERE with the token from the email).
  • In the Body tab, enter your new password:
{
  "password": "mynewpassword123"
}
  • Click Send.

You should see a 200 response with Password reset successful plus accessToken and refreshToken in the response body. The user is immediately logged in.

  • Verify the new password works by sending a POST to http://localhost:5000/api/auth/login with the same email and your new password (mynewpassword123).
  • You should see a 200 response with Login successful and fresh tokens.

Getting "Invalid or expired token" on reset?

  • Make sure you are copying the full 64-character hex token from the email, not the reset URL. The token is the string after /reset-password/ in the URL.
  • The token expires after 10 minutes. If you waited too long, request a new one via /forgot-password.
  • Each token can only be used once. After a successful reset, the token is cleared from the database.

Why am I getting an invalid token error when trying to reset my password?

✔️ Awesome, I've got everything!

Great. Double check you have saved both controllers/authController.js and routes/authRoutes.js.

ⓧ I'd like to double check the full code

Here is the full routes/authRoutes.js for comparison:

const express = require('express');
const { signup, login, logout, refreshAccessToken, forgotPassword, resetPassword } = require('../controllers/authController');
const { validateSignup, validateLogin } = require('../middleware/validate');
const { authLimiter } = require('../middleware/rateLimiter');
const { protect } = require('../middleware/auth');

const router = express.Router();

router.post('/signup', authLimiter, validateSignup, signup);
router.post('/login', authLimiter, validateLogin, login);
router.post('/logout', protect, logout);
router.post('/refresh-token', refreshAccessToken);
router.post('/forgot-password', authLimiter, forgotPassword);
router.put('/reset-password/:token', resetPassword);

module.exports = router;

For controllers/authController.js, scroll up and confirm these pieces are present in order:

  • The require imports for jsonwebtoken, crypto, User, and sendEmail at the top.
  • generateAccessToken and generateRefreshToken helper functions.
  • signup, login, logout, and refreshAccessToken functions (from previous steps).
  • forgotPassword function (added in this step).
  • resetPassword function (added in this step).
  • module.exports exporting all six functions.

Your authentication system now covers the full user lifecycle: register, log in, refresh tokens, log out, and recover from a forgotten password. Next up, you will harden the system by sanitizing inputs against XSS and adding account lockout to stop brute-force attacks that rotate IP addresses.

Sanitize Inputs and Lock Out Brute-Force Attackers

Your validation middleware rejects malformed inputs, but it does not clean them. A user could send <script>alert('xss')</script> as their name and your API would store it in the database verbatim.

Your rate limiter also has a blind spot. It throttles per IP, but a determined attacker can rotate IP addresses. In this step, you will add input sanitization to strip dangerous characters and account lockout to freeze a specific account after too many failed login attempts.

In this step, get ready to:
  • Add sanitization to the existing validation middleware to escape HTML and normalize emails.
  • Add account lockout fields to the User model and enforce them in the login flow.
  • Test both features in Postman.
Add sanitization to the validation middleware

Right now your validateSignup and validateLogin chains check that fields exist and have the right format, but they pass the raw values straight through. Adding sanitizers to the same chains ensures data is both valid AND clean before it reaches your controller.

  • Open middleware/validate.js and replace its contents with this updated version that adds sanitizers:
const { body, validationResult } = require('express-validator');

// Check for validation errors and return 400 if any exist
const handleValidationErrors = (req, res, next) => {
  const errors = validationResult(req);
  if (!errors.isEmpty()) {
    return res.status(400).json({ errors: errors.array() });
  }
  next();
};

// Validation + sanitization rules for signup
const validateSignup = [
  body('name')
    .trim()
    .escape()
    .notEmpty()
    .withMessage('Name is required'),
  body('email')
    .isEmail()
    .withMessage('Please provide a valid email')
    .normalizeEmail(),
  body('password')
    .isLength({ min: 6 })
    .withMessage('Password must be at least 6 characters'),
  handleValidationErrors,
];

What changed here?

  • .escape() on the name field converts HTML special characters (<, >, &, ") into their safe entity equivalents. A stored <script> tag becomes &lt;script&gt;.
  • .normalizeEmail() standardizes email addresses: lowercases the domain, removes dots from Gmail local parts, and strips sub-addressing (the +tag part). This prevents duplicate accounts.
  • .trim() removes leading and trailing whitespace so " John " becomes "John".
  • Below the signup rules, paste the login validation and export:
// Validation + sanitization rules for login
const validateLogin = [
  body('email')
    .isEmail()
    .withMessage('Please provide a valid email')
    .normalizeEmail(),
  body('password')
    .notEmpty()
    .withMessage('Password is required'),
  handleValidationErrors,
];

module.exports = { validateSignup, validateLogin };
  • Save middleware/validate.js.
  • Restart your server to confirm there are no errors:
node server.js

You should see Server running on port 5000 and MongoDB connected with no errors.

Now test the sanitization. What do you think will happen if you send a signup request with HTML in the name field?

  • In Postman, send a POST request to http://localhost:5000/api/auth/signup with this body:
{
  "name": "<script>alert('xss')</script>",
  "email": "sanitize-test@example.com",
  "password": "test123"
}
  • Click Send.

You get a 201 response. Look at the name field. Instead of raw HTML, you see escaped entities. The sanitizer neutralized the dangerous characters before they reached your database.

Name still shows raw HTML?

Make sure you saved middleware/validate.js AND restarted the server. The old middleware without .escape() is still loaded until you restart.

help me debug why my input sanitization is not escaping HTML characters

Add account lockout to the User model and login

Rate limiting stops one IP from spamming, but it cannot stop a botnet rotating through thousands of IPs targeting the same account. Account lockout protects the account itself by freezing it after 5 failed login attempts, regardless of source IP.

  • Open models/User.js and add these two fields inside the schema, after the resetPasswordExpire field:
  // Account lockout: track failed attempts
  loginAttempts: {
    type: Number,
    default: 0,
  },
  lockUntil: {
    type: Date,
  },

What do these fields do?

loginAttempts counts consecutive failed logins. lockUntil stores the timestamp when the lock expires. If lockUntil is in the future, the account is frozen.

A successful login resets both fields to zero. This means a user who remembers their password on attempt 4 of 5 never gets locked out.

  • Save models/User.js.
  • Now open controllers/authController.js and replace the entire login function. Start with the lockout check:
// Authenticate user with account lockout protection
const login = async (req, res) => {
  try {
    const { email, password } = req.body;
    const MAX_ATTEMPTS = 5;
    const LOCK_TIME = 15 * 60 * 1000;

    const user = await User.findOne({ email })
      .select('+password +loginAttempts +lockUntil');
    if (!user) {
      return res.status(401).json({ message: 'Invalid credentials' });
    }

    // Check if account is currently locked
    if (user.lockUntil && user.lockUntil > Date.now()) {
      return res.status(423).json({
        message: 'Account locked. Try again in 15 minutes.',
      });
    }

    const isMatch = await user.comparePassword(password);
    if (!isMatch) {
      // Increment failed attempts and lock if threshold reached
      user.loginAttempts = (user.loginAttempts || 0) + 1;
      if (user.loginAttempts >= MAX_ATTEMPTS) {
        user.lockUntil = new Date(Date.now() + LOCK_TIME);
      }
      await user.save();
      return res.status(401).json({ message: 'Invalid credentials' });
    }
  • Continue with the successful login section that resets the lockout counters:
    // Successful login: reset lockout counters
    user.loginAttempts = 0;
    user.lockUntil = undefined;

    const accessToken = generateAccessToken(user._id);
    const refreshToken = generateRefreshToken(user._id);
    user.refreshToken = refreshToken;
    await user.save();

    res.status(200).json({
      message: 'Login successful',
      accessToken,
      refreshToken,
      user: { id: user._id, name: user.name, email: user.email },
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

How does account lockout work?

  • .select('+loginAttempts +lockUntil') pulls in the lockout fields that are excluded from queries by default.
  • If lockUntil is in the future, the function returns 423 (Locked) immediately without checking the password.
  • On each failed attempt, loginAttempts increments. At 5 failures, lockUntil is set to 15 minutes from now.
  • A successful login clears both counters so the user starts fresh.
  • Save controllers/authController.js.
  • Restart your server:
node server.js

Now test the lockout. What do you think will happen on the 6th failed login attempt?

  • In Postman, send a POST to http://localhost:5000/api/auth/login with a registered email but the wrong password. Send it 5 times.
  • On the 6th attempt, click Send (with either the wrong or correct password).

You get a 423 response with Account locked. Try again in 15 minutes.. The account is frozen regardless of which IP sends the request.

Not getting 423 after 5 attempts?

  • The rate limiter may trigger first (5 requests per 15 minutes per IP). Restart the server to reset the in-memory counter, then try again quickly.
  • Make sure you are sending to /api/auth/login (not /signup).
  • Confirm the updated login function includes .select('+loginAttempts +lockUntil') on the User query.

help me debug why account lockout is not triggering after failed login attempts

Your API now sanitizes every input to prevent XSS and locks accounts after repeated failed logins. Next up, you will add refresh token reuse detection and a password change endpoint for logged-in users.

Detect Token Theft and Add Password Change

Your accounts are now locked after brute-force attempts and your inputs are sanitized. But there is still a vulnerability in the token refresh flow: if an attacker intercepts a refresh token, your current rotation replaces it silently. The old token just stops working and nobody is alerted.

In this step, you will add reuse detection so that if a previously rotated token is used again, all sessions for that user are immediately invalidated. You will also build a password change endpoint for logged-in users who want to update their credentials without going through the email reset flow.

In this step, get ready to:
  • Add refresh token reuse detection that invalidates all sessions on suspected theft.
  • Build a protected password change endpoint for logged-in users.
  • Test both features in Postman.
Add refresh token reuse detection

Your current refreshAccessToken function checks that the incoming token matches what is stored on the user. If someone uses an old (already-rotated) token, it fails with 401. But that failure does not trigger any alarm. The attacker just knows they need a fresher token.

Industry standard is to treat a reused token as evidence of theft. If a token that was already rotated out appears again, the only explanation is that either the legitimate user or an attacker is out of sync. The safest response is to invalidate everything and force re-login.

  • In controllers/authController.js, find the refreshAccessToken function.
  • Replace it with this updated version that detects reuse:
// Verify refresh token with reuse detection
const refreshAccessToken = async (req, res) => {
  try {
    const { refreshToken } = req.body;
    if (!refreshToken) {
      return res.status(400).json({ message: 'Refresh token is required' });
    }

    let decoded;
    try {
      decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    } catch (err) {
      return res.status(401).json({ message: 'Invalid or expired refresh token' });
    }

    const user = await User.findById(decoded.id).select('+refreshToken');
    if (!user) {
      return res.status(401).json({ message: 'Invalid refresh token' });
    }
  • Continue with the reuse detection logic and token rotation:
    // REUSE DETECTION: token is valid but doesn't match stored token
    if (user.refreshToken !== refreshToken) {
      // Possible theft: invalidate all sessions for this user
      user.refreshToken = undefined;
      await user.save();
      return res.status(401).json({
        message: 'Token reuse detected. All sessions invalidated. Please log in again.',
      });
    }

    // Token is valid and matches: rotate normally
    const newAccessToken = generateAccessToken(user._id);
    const newRefreshToken = generateRefreshToken(user._id);
    user.refreshToken = newRefreshToken;
    await user.save();

    res.status(200).json({
      accessToken: newAccessToken,
      refreshToken: newRefreshToken,
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

How does reuse detection work?

The key change is the check: if the incoming token decodes successfully (it was signed by your server and has not expired) but does NOT match the stored token, it means the token was already rotated out. Someone is replaying an old token.

By clearing refreshToken entirely, both the attacker and the legitimate user lose their session. The legitimate user must re-authenticate. This is intentionally aggressive because the alternative (doing nothing) lets an attacker maintain access indefinitely.

  • Save controllers/authController.js.
  • Restart your server:
node server.js

Now test reuse detection. The trick is to use a refresh token that has already been rotated.

  • In Postman, log in to get a fresh refreshToken. Copy it and save it somewhere.
  • Use that refresh token to call POST http://localhost:5000/api/auth/refresh-token. You get a new token pair. The old refresh token is now rotated out.
  • Now paste the OLD refresh token (the one you saved) back into the body and click Send again.

You get a 401 with Token reuse detected. All sessions invalidated. Please log in again.. The user's stored refresh token is now cleared, meaning even the new token from step 2 will no longer work.

Still getting a normal token rotation instead of 401?

  • Make sure you are sending the OLD token (from the first login), not the NEW one you just received from the refresh call.
  • Confirm you restarted the server after saving the updated refreshAccessToken function.

help me test refresh token reuse detection in my auth API

Build the password change endpoint

Password reset via email is for users who forgot their password. Password change is for logged-in users who know their current password and want to update it from within the app (the "Settings > Change Password" flow).

  • In controllers/authController.js, add this changePassword function after resetPassword and before module.exports:
// Change password for logged-in users
const changePassword = async (req, res) => {
  try {
    const { currentPassword, newPassword } = req.body;
    if (!currentPassword || !newPassword) {
      return res.status(400).json({
        message: 'Please provide current and new passwords',
      });
    }
    if (newPassword.length < 6) {
      return res.status(400).json({
        message: 'New password must be at least 6 characters',
      });
    }

    // Fetch the user with their password field
    const user = await User.findById(req.user._id).select('+password');
    const isMatch = await user.comparePassword(currentPassword);
    if (!isMatch) {
      return res.status(401).json({ message: 'Current password is incorrect' });
    }

    // Set new password (pre-save hook hashes it automatically)
    user.password = newPassword;
    await user.save();

    res.status(200).json({ message: 'Password changed successfully' });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

What does this code do?

It requires the user to prove they know their current password before allowing a change. This prevents an attacker who steals an access token from silently changing the password.

The pre-save hook on the User model automatically hashes the new password via bcryptjs before storing it, just like it does during signup.

  • Update the module.exports at the bottom of controllers/authController.js to include changePassword:
module.exports = { signup, login, logout, refreshAccessToken, forgotPassword, resetPassword, changePassword };
  • Open routes/authRoutes.js.
  • Update the import at the top to include changePassword:
const { signup, login, logout, refreshAccessToken, forgotPassword, resetPassword, changePassword } = require('../controllers/authController');
  • Add this route after the /reset-password/:token route:
// Requires authentication: user must be logged in
router.put('/change-password', protect, changePassword);
  • Save both files and restart your server:
node server.js
  • In Postman, log in to get a fresh access token.
  • Create a new PUT request to http://localhost:5000/api/auth/change-password.
  • In the Authorization tab, select Bearer Token and paste your access token.
  • Set the body to:
{
  "currentPassword": "test123",
  "newPassword": "mynewsecurepass"
}
  • Click Send.

You get a 200 response with Password changed successfully. Verify it worked by logging in with the new password.

Getting 401 on the change-password request?

  • Make sure you have a valid access token in the Authorization header. The protect middleware requires it.
  • If your token expired (15-minute lifespan), log in again to get a fresh one.

help me debug why my change-password endpoint returns 401

Your auth system now detects token theft and provides a proper password change flow for logged-in users. Next up, you will add pagination to your product listings so the API scales gracefully as the catalog grows.

Paginate Product Listings

Your token system is now hardened against theft and your users can change their passwords securely. But there is one more gap in your API: the GET /api/products endpoint returns every single product in the database in one response.

With 10 products, that is fine. With 10,000 products, it crashes the client and wastes bandwidth. In this step, you will add pagination so consumers can request products in manageable pages with ?page=1&limit=10 and know the total count for building UI page controls.

In this step, get ready to:
  • Update the getProducts controller to accept page and limit query parameters.
  • Return paginated results with total count and page metadata.
  • Test pagination in Postman with different page sizes.
Update getProducts with pagination

Pagination in Mongoose uses .skip() and .limit() on the query, combined with .countDocuments() to get the total count. You will read page and limit from the query string and calculate the correct offset.

  • Open controllers/productController.js and replace the getProducts function with this paginated version:
// Public: paginated product listing
const getProducts = async (req, res) => {
  try {
    // Parse page and limit from query string (defaults: page 1, 10 per page)
    const page = parseInt(req.query.page) || 1;
    const limit = parseInt(req.query.limit) || 10;
    const skip = (page - 1) * limit;

    // Run the paginated query and count in parallel
    const [products, total] = await Promise.all([
      Product.find()
        .populate('createdBy', 'name email')
        .sort({ createdAt: -1 })
        .skip(skip)
        .limit(limit),
      Product.countDocuments(),
    ]);

    res.status(200).json({
      products,
      page,
      limit,
      total,
      pages: Math.ceil(total / limit),
    });
  } catch (error) {
    res.status(500).json({ message: 'Server error', error: error.message });
  }
};

What does this code do?

  • parseInt(req.query.page) || 1 reads the page query parameter and defaults to 1 if missing or invalid.
  • skip = (page - 1) * limit calculates how many documents to skip. Page 1 skips 0, page 2 skips 10, page 3 skips 20, and so on.
  • Promise.all() runs the paginated query and the count query in parallel for better performance.
  • .sort({ createdAt: -1 }) ensures newest products appear first and results are deterministic across pages.
  • The response includes total (total documents) and pages (total pages) so clients can build pagination UI.
  • Save controllers/productController.js.
  • Restart your server:
node server.js

Now test pagination. Before you send the request, what do you think the response shape will look like compared to before?

  • In Postman, send a GET request to http://localhost:5000/api/products?page=1&limit=2.

You get a response with a products array containing at most 2 items, plus page, limit, total, and pages metadata fields.

  • Try http://localhost:5000/api/products?page=2&limit=2 to see the next page of results.
  • Try http://localhost:5000/api/products with no query parameters. You should get the first 10 products (the default).

Getting an empty products array?

  • If you only have 1-2 products in the database, page 2 will be empty. Create a few more products first via POST /api/products with your access token.
  • Confirm your query string uses ?page=1&limit=2 (not a JSON body). Pagination parameters come from the URL.

help me debug why my paginated products endpoint returns empty results

Your product catalog API is now production-hardened. It authenticates users, authorizes actions, sends transactional emails, sanitizes inputs, locks out attackers, detects token theft, and serves paginated results. This is the foundation every real-world API needs.

Secret mission

Add Email Verification

Right now, users can sign up with any email address and immediately log in. There's no proof they actually own that email. Your mission: add an email verification step so users must confirm their email before they can log in.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally and uses free-tier cloud services, so there are no ongoing costs.

Resources you used:

  • MongoDB Atlas database (stores your test users and products collections).
  • Resend API key (used for sending welcome and password reset emails).
  • Local project files and npm packages (your auth-enabled product catalog API).

Keep everything running

No action needed. Choose this if you're still actively building or plan to add more features to your API.

  • Your MongoDB Atlas free-tier cluster stays active with no charges.
  • Your Resend account remains on the free plan (3,000 emails per month).
  • Your local project files stay on your machine ready to run with node server.js.

Pause - I'll come back to this later

Shut down running processes to free up your terminal. Your data and files stay intact for when you return.

  • Stop the Node.js server by pressing Ctrl+C in the terminal where it is running.
  • Your MongoDB Atlas data remains stored in the cloud at no cost.
  • Your Resend API key stays active. No action needed.
  • To resume later, open a terminal in your project folder (the one containing server.js) and run node server.js.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

Delete your MongoDB Atlas data
  • Log in to MongoDB Atlas at cloud.mongodb.com.
  • Click your cluster name, then select the Collections tab.
  • Select the database your project uses (the one from your MONGO_URI connection string).
  • Click the trash icon next to the database name and confirm by typing the database name when prompted.
Revoke your Resend API key
  • Log in to Resend at resend.com.
  • Navigate to API Keys in the sidebar.
  • Find the key you created for this project and delete it.
Delete your local project files
  • Stop the Node.js server by pressing Ctrl+C if it is still running.
  • Navigate to the parent directory of your project folder (the folder containing server.js) and delete it.

On macOS or Linux, run:

cd ~/Desktop
rm -rf product-catalog-api

On Windows, run:

cd ~\Desktop
rmdir /s /q product-catalog-api

Adjust the folder name

If you named your project folder something different, replace product-catalog-api with whatever you called it.

Nice Work!

Nice Work!

Nice work! You just built a production-hardened authentication layer on top of your product catalog API, complete with dual-token auth, transactional email, and ownership-based authorization.

Here's what you learned:

  • Built a full signup, login, logout, and token refresh system with secure password hashing, input validation and sanitization, rate limiting, and account lockout to stop brute-force attacks.
  • Protected product routes with auth middleware, ownership-based authorization, and paginated listings so only authenticated owners can modify their products and consumers get manageable page-sized responses.
  • Integrated Resend for transactional emails (welcome and password reset), added refresh token reuse detection to invalidate stolen sessions, and built a password change endpoint for logged-in users.
  • Secret Mission: Added email verification requiring users to confirm their email before logging in.

Ready to quiz yourself?