Build a Secure REST API with Node.js, Express, MongoDB, and JWT

by Didin J. on May 16, 2025 Build a Secure REST API with Node.js, Express, MongoDB, and JWT

Learn how to build a secure Node.js REST API with Express, MongoDB, JWT authentication, role-based access, and input validation

In today’s digital world, securing your application's data is as important as building its features. REST APIs are the backbone of modern web and mobile applications, and without proper authentication and authorization, your app is vulnerable to attacks.

This tutorial will walk you through building a secure RESTful API using Node.js, Express.js, MongoDB, and JWT (JSON Web Tokens). You’ll learn how to implement essential security measures such as password hashing, token-based authentication, and route protection.

By the end of this guide, you’ll be able to:

  • Set up a basic Express server and connect to MongoDB
  • Register and authenticate users securely using JWT
  • Protect your API endpoints with middleware
  • Understand and implement security best practices

Whether you’re building a full-stack app, a mobile backend, or a microservice, these are the foundational skills you need to keep your users and data safe.

Let’s get started!


1. Prerequisites

Before we begin building the secure REST API, make sure you have the following tools and knowledge in place:

Tools and Software

  • Node.js and npm: Ensure Node.js https://nodejs.org (v14 or later) and npm are installed. You can download it from nodejs.org.
  • MongoDB: Either install MongoDB locally or use MongoDB Atlas https://www.mongodb.com/products/platform/atlas-database for a free cloud-based solution.
  • Postman or cURL: For testing your API endpoints.

Basic Knowledge

  • JavaScript (ES6+): Familiarity with modern JavaScript syntax.
  • REST APIs: Understanding of REST principles like HTTP methods (GET, POST, PUT, DELETE) and status codes.
  • JSON: Ability to work with JSON data format.

If you’re comfortable with the above, you’re ready to start building the project. In the next section, we’ll set up the project and install all the dependencies we’ll need.


2. Project Setup

Let’s start by creating the project structure and installing the required dependencies

Step 1: Initialize the Project

Open your terminal and run the following commands:

mkdir secure-node-api
cd secure-node-api
npm init -y

This will create a new project folder with a basic package.json file.

Step 2: Install Dependencies

Now install the necessary packages:

npm install express mongoose bcryptjs jsonwebtoken dotenv cors

And install nodemon as a development dependency to auto-restart the server on changes:

Step 3: Update package.json Scripts

In your package.json, replace the scripts section with:

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

Step 4: Project Folder Structure

Create the following folder and file structure:

secure-node-api/
├── config/
│   └── db.js
├── middleware/
│   └── auth.js
├── models/
│   └── User.js
├── routes/
│   └── auth.js
├── .env
├── server.js

You can create these manually or use the command line:

mkdir config middleware models routes
touch config/db.js middleware/auth.js models/User.js routes/auth.js server.js .env

Step 5: Add Basic Server Code

In server.js, add the following starter code:

import express, { json } from "express";
import { connect } from "mongoose";
import { config } from "dotenv";
import cors from "cors";

// Load environment variables
config();
const app = express();
app.use(cors());
app.use(json());

// Routes
app.use("/api/auth", require("./routes/auth"));

// Start server
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

That’s it for the project setup! In the next section, we’ll connect to MongoDB and prepare the database for storing users securely.


3. Connect to MongoDB

In this step, we'll configure a connection to MongoDB using Mongoose. You can use either a local MongoDB instance or a free MongoDB Atlas https://www.mongodb.com/products/platform/atlas-database cluster.

Step 1: Create a .env File

Add your MongoDB connection string and any other secrets to .env:

PORT=3000
MONGO_URI=mongodb://localhost:27017/secureapi
JWT_SECRET=your_jwt_secret_key_here

🔐 Replace your_jwt_secret_key_here with a strong, random string. If you're using MongoDB Atlas, use the connection string provided in your Atlas dashboard.

Step 2: Set Up the Database Connection

In config/db.js, add the following:

import mongoose from "mongoose";

const connectDB = async () => {
  try {
    await mongoose.connect(process.env.MONGO_URI, {
      useNewUrlParser: true,
      useUnifiedTopology: true
    });
    console.log("MongoDB connected");
  } catch (err) {
    console.error(err.message);
    process.exit(1);
  }
};

export default connectDB;

Step 3: Update server.js to use the DB Connection

At the top of server.js, import the connectDB function:

import connectDB from "./config/db.js";

Then, call it before starting the server:

connectDB();

Your updated server.js should look like this:

import express, { json } from "express";
import { config } from "dotenv";
import cors from "cors";
import connectDB from "./config/db.js";

// Load environment variables
config();
connectDB();

const app = express();
app.use(cors());
app.use(json());

// Start server
const PORT = process.env.PORT || 3000;

app.listen(PORT, () => console.log(`Server running on port ${PORT}`));

At this point, your server should be successfully connecting to MongoDB. You can test it by running:

npm run dev

If everything is configured correctly, you’ll see:

MongoDB connected
Server running on port 5000


4. Create the User Model

In this section, we’ll define a User schema for MongoDB using Mongoose. This model will store user details such as name, email, and a hashed password.

Step 1: Create the User Schema

In models/User.js, add the following code:

import mongoose from 'mongoose';

const UserSchema = new mongoose.Schema({
  name: {
    type: String,
    required: true,
    trim: true
  },
  email: {
    type: String,
    required: true,
    unique: true,
    lowercase: true
  },
  password: {
    type: String,
    required: true
  }
}, {
  timestamps: true
});

export default mongoose.model('User', UserSchema);

Schema Fields Explained

  • name: Full name of the user.
  • email: Unique email address used for login.
  • password: Hashed password stored securely.
  • timestamps: Automatically adds createdAt and updatedAt fields.

Step 2: Install Mongoose Validation Plugin (Optional)

For cleaner error messages on duplicate emails or schema violations, consider installing mongoose-unique-validator:

npm install --legacy-peer-deps mongoose-unique-validator

Then update your model like this:

import mongoose from 'mongoose';
import uniqueValidator from 'mongoose-unique-validator';

const UserSchema = new mongoose.Schema({
  name: { type: String, required: true, trim: true },
  email: { type: String, required: true, unique: true, lowercase: true },
  password: { type: String, required: true }
}, { timestamps: true });

UserSchema.plugin(uniqueValidator);

export default mongoose.model('User', UserSchema);

Now you have a fully defined User model ready to store and validate user data securely.


5. Auth Routes (Register & Login)

In this section, we’ll:

  • Create routes for user registration and login
  • Use bcryptjs to hash passwords
  • Use jsonwebtoken to generate a secure token

Step 1: Create routes/auth.js

In routes/auth.js, add the following code:

import express from 'express';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';

import User from '../models/User.js';

const router = express.Router();

// @route   POST /api/auth/register
// @desc    Register new user
// @access  Public
router.post('/register', async (req, res) => {
  const { name, email, password } = req.body;

  try {
    // Check if user already exists
    let user = await User.findOne({ email });
    if (user) return res.status(400).json({ message: 'User already exists' });

    // Hash password
    const salt = await bcrypt.genSalt(10);
    const hashedPassword = await bcrypt.hash(password, salt);

    // Create new user
    user = new User({ name, email, password: hashedPassword });
    await user.save();

    // Generate JWT
    const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, {
      expiresIn: '1h'
    });

    res.status(201).json({
      token,
      user: { id: user._id, name: user.name, email: user.email }
    });
  } catch (err) {
    console.error(err.message);
    res.status(500).send('Server error');
  }
});

// @route   POST /api/auth/login
// @desc    Authenticate user & get token
// @access  Public
router.post('/login', async (req, res) => {
  const { email, password } = req.body;

  try {
    // Check for user
    const user = await User.findOne({ email });
    if (!user) return res.status(400).json({ message: 'Invalid credentials' });

    // Compare password
    const isMatch = await bcrypt.compare(password, user.password);
    if (!isMatch) return res.status(400).json({ message: 'Invalid credentials' });

    // Generate JWT
    const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, {
      expiresIn: '1h'
    });

    res.json({
      token,
      user: { id: user._id, name: user.name, email: user.email }
    });
  } catch (err) {
    console.error(err.message);
    res.status(500).send('Server error');
  }
});

export default router;

Step 2: Add the Route to server.js

In server.js, make sure you’ve already added:

import authRoutes from './routes/auth.js';
app.use('/api/auth', authRoutes);

Now your authentication routes are live at:

  • POST /api/auth/register
  • POST /api/auth/login

Quick Test with Postman

Register:

  • Method: POST
  • URL: http://localhost:3000/api/auth/register
  • Body (JSON):
{
  "name": "John Doe",
  "email": "[email protected]",
  "password": "123456"
}

Build a Secure REST API with Node.js, Express, MongoDB, and JWT - postman - register

Login:

  • Method: POST
  • URL: http://localhost:3000/api/auth/login
  • Body (JSON):
{
  "email": "[email protected]",
  "password": "123456"
}

Build a Secure REST API with Node.js, Express, MongoDB, and JWT - Postman login

You're now able to securely register and log in users using hashed passwords and JWT.


6. JWT Middleware for Protected Routes

Now that users can register and log in, we need to protect routes so that only authenticated users with a valid token can access them.

We’ll do this by creating a custom Express middleware that:

  • Checks for the presence of a JWT in the Authorization header.
  • Verifies the token’s validity.
  • Adds the decoded user info to the req object.

1. Create a Middleware File

middleware/auth.js:

import jwt from "jsonwebtoken";

const auth = (req, res, next) => {
  const authHeader = req.headers.authorization;
  if (!authHeader || !authHeader.startsWith("Bearer "))
    return res.status(401).json({ message: "No token, authorization denied" });

  const token = authHeader.split(" ")[1];
  try {
    // Verify and decode the token
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    // decoded is { id: user._id, iat: ..., exp: ... }
    req.user = decoded; // ← assign the whole decoded payload
    next();
  } catch (err) {
    res.status(401).json({ message: "Token is not valid" });
  }
};

export default auth;

2. Create a Protected Route to Test It

Create a new route file:

routes/protected.js

import express from 'express';
import auth from '../middleware/auth.js';

const router = express.Router();

router.get("/dashboard", auth, (req, res) => {
  res.json({
    message: `Welcome, user with ID ${req.user.id}`
  });
});

export default router;

3. Register This Route in server.js

In your main server.js, import and use it:

import protectedRoutes from './routes/protected.js';

app.use('/api', protectedRoutes);

4. Test with CURL or Postman

  • First, log in to get the JWT token:
POST http://localhost:3000/api/auth/login
Content-Type: application/json

{
  "email": "[email protected]",
  "password": "123456"
}

Copy the token from the response.

  • Then call the protected route:
curl --location 'http://localhost:3000/api/dashboard' \
--header 'Authorization: Bearer YOUR_TOKEN_HERE'

If the token is valid, you'll get:

{
  "message": "Welcome, user with ID 123abc..."
}

If the token is missing or invalid:

{
  "message": "No token, authorization denied"
}


7. Create Protected CRUD Routes (Example: Posts)

In this section, we'll:

  1. Create a Mongoose model for a Post
  2. Set up protected routes for:
    • Creating a post
    • Reading all posts
    • Updating a post
    • Deleting a post
  3. 3. Use the auth middleware to restrict access

1. Create the Post Model

models/Post.js

import mongoose from 'mongoose';

const PostSchema = new mongoose.Schema(
  {
    title: {
      type: String,
      required: true,
    },
    content: {
      type: String,
      required: true,
    },
    user: {
      type: mongoose.Schema.Types.ObjectId,
      ref: 'User',
      required: true,
    },
  },
  { timestamps: true }
);

export default mongoose.model('Post', PostSchema);

2. Create the Post Routes

routes/posts.js

import express from 'express';
import Post from '../models/Post.js';
import auth from '../middleware/auth.js';

const router = express.Router();

// Create a new post
router.post('/', auth, async (req, res) => {
  try {
    const newPost = new Post({
      title: req.body.title,
      content: req.body.content,
      user: req.user.id,
    });

    const savedPost = await newPost.save();
    res.status(201).json(savedPost);
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

// Get all posts (for the authenticated user)
router.get('/', auth, async (req, res) => {
  try {
    const posts = await Post.find({ user: req.user.id }).sort({ createdAt: -1 });
    res.json(posts);
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

// Update a post
router.put('/:id', auth, async (req, res) => {
  try {
    const post = await Post.findOne({ _id: req.params.id, user: req.user.id });
    if (!post) return res.status(404).json({ message: 'Post not found' });

    post.title = req.body.title || post.title;
    post.content = req.body.content || post.content;

    const updatedPost = await post.save();
    res.json(updatedPost);
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

// Delete a post
router.delete('/:id', auth, async (req, res) => {
  try {
    const post = await Post.findOneAndDelete({ _id: req.params.id, user: req.user.id });
    if (!post) return res.status(404).json({ message: 'Post not found' });

    res.json({ message: 'Post deleted successfully' });
  } catch (err) {
    res.status(500).json({ message: err.message });
  }
});

export default router;

3. Register the Route in server.js

Update your server.js:

import postRoutes from './routes/posts.js';

app.use('/api/posts', postRoutes);

4. Test the CRUD Endpoints

Use Postman or curl with your JWT token to test:

Create Post

curl --location 'http://localhost:3000/api/posts' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_AUTH_TOKEN' \
--data '{
  "title": "First Post",
  "content": "This is a secure post!"
}'

Get Posts

curl --location 'http://localhost:3000/api/posts' \
--header 'Authorization: Bearer YOUR_AUTH_TOKEN'

Update Post

curl --location --request PUT 'http://localhost:3000/api/posts/6826a37c71519b349ab6f19b' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer YOUR_AUTH_TOKEN' \
--data '{
  "title": "Updated Post Title"
}'

Delete Post

curl --location --request DELETE 'http://localhost:3000/api/posts/6826a37c71519b349ab6f19b' \
--header 'Authorization: Bearer YOUR_AUTH_TOKEN'

That’s a complete protected CRUD setup!


8. User Role Authorization (Admin vs User)

1. Add a role field to the User Model

Update models/User.js:

const UserSchema = new mongoose.Schema(
  {
    name: { type: String, required: true, trim: true },
    email: { type: String, required: true, unique: true, lowercase: true },
    password: { type: String, required: true },
    role: { type: String, enum: ["user", "admin"], default: "user" }
  },
  { timestamps: true }
);

2. Modify JWT Payload to Include Role

In your routes/auth.js, inside the login route:

  const token = jwt.sign(
    { id: user._id, role: user.role },
    process.env.JWT_SECRET,
    { expiresIn: "1h" }
  );

3. Create Admin Middleware

middleware/admin.js

const admin = (req, res, next) => {
  if (req.user.role !== 'admin') {
    return res.status(403).json({ message: 'Access denied: Admins only' });
  }
  next();
};

export default admin;

4. Use Role Middleware in a Route

In routes/posts.js or create a new route like routes/admin.js:

import express from "express";
import admin from "../middleware/admin.js";

const router = express.Router();

router.get("/admin-only", auth, admin, (req, res) => {
  res.json({ message: "Welcome, Admin!" });
});

export default router;

Now, only users with the role "admin" can access this route.


9. Input Validation with Joi

We’ll use Joi for clean, schema-based validation.

1. Install Joi

npm install --legacy-peer-deps joi

2. Create Validation Schemas

Create validators/auth.js

import Joi from 'joi';

export const registerSchema = Joi.object({
  name: Joi.string().min(2).max(30).required(),
  email: Joi.string().email().required(),
  password: Joi.string().min(6).required(),
});

export const loginSchema = Joi.object({
  email: Joi.string().email().required(),
  password: Joi.string().required(),
});

3. Validate Input in Routes

In routes/auth.js:

import { registerSchema, loginSchema } from '../validators/auth.js';

router.post('/register', async (req, res) => {
  const { error } = registerSchema.validate(req.body);
  if (error) return res.status(400).json({ message: error.details[0].message });
  // continue with registration...
});

Same for login:

router.post('/login', async (req, res) => {
  const { error } = loginSchema.validate(req.body);
  if (error) return res.status(400).json({ message: error.details[0].message });
  // continue with login...
});

Optional: Zod Instead of Joi?

If you prefer Zod:

npm install zod

Then you can define and parse like:

import { z } from 'zod';

const registerSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  password: z.string().min(6),
});

try {
  registerSchema.parse(req.body);
} catch (err) {
  return res.status(400).json({ message: err.errors[0].message });
}

Both Joi and Zod are great — Joi is more common in Node.js/Express setups.


10. Conclusion

In this tutorial, you learned how to build a secure and modern REST API using Node.js, Express, MongoDB, and JWT authentication. We walked through every essential step—from project setup, user registration, and login, to protecting routes using tokens.

You also enhanced your API by:

  • Implementing role-based authorization to control access for different users (e.g., admin vs. user)
  • Adding data validation with Joi (or Zod) to ensure safe and clean user input
  • Creating protected CRUD routes with real-world examples, like managing posts

This structure provides a strong foundation for building scalable and secure backends. You can easily extend it further by adding:

  • Password reset with email
  • Refresh tokens
  • Rate limiting & security headers
  • Logging and monitoring

With this backend setup, you're ready to connect it to any frontend framework like React, Angular, Vue, or a mobile app built with Flutter or React Native.

You can find the full source code on our GitHub Repository.

That just the basic. If you need more deep learning about Node.js, Express.js, PostgreSQL, Vue.js and GraphQL or related you can take the following cheap course:

Thanks!