NodeJs

Node.js, Express & MongoDB JWT Authentication Tutorial (with HTTP-only Cookies)

Node.js + Express + MongoDB + JWT Authentication with HTTP-only Cookies — Step-by-Step Guide

In this detailed tutorial, we’ll build a complete authentication system using Node.js, Express, MongoDB, and JWT (JSON Web Token).
We’ll store JWT securely inside HTTP-only cookies to protect users from XSS attacks — making it a professional-grade backend authentication setup.

By the end of this post, you’ll understand how to:

  • Setup Node.js and Express project structure
  • Connect with MongoDB using Mongoose
  • Register and login users securely
  • Generate and verify JWT tokens
  • Store JWT in HTTP-only cookies
  • Protect private routes using authentication middleware
  • Implement logout by clearing cookies

✅ Step 1: Create Project Folder and Initialize Node.js

Open your terminal and create a new project folder:


mkdir node-auth-cookie
cd node-auth-cookie
npm init -y
  

This will create a package.json file. Now install the required dependencies:


npm install express mongoose bcryptjs jsonwebtoken cookie-parser cors dotenv
npm install --save-dev nodemon
  

Let’s quickly understand what these packages do:

  • express — web framework for building REST APIs.
  • mongoose — for MongoDB connection and schema creation.
  • bcryptjs — for password hashing.
  • jsonwebtoken — for creating and verifying tokens.
  • cookie-parser — to handle cookies in Express.
  • dotenv — to manage environment variables.

✅ Step 2: Project Structure

Your folder structure should look like this:


node-auth-cookie/
├── models/
│   └── User.js
├── middleware/
│   └── auth.js
├── routes/
│   └── auth.js
├── server.js
├── .env
├── package.json
  

✅ Step 3: Setup Environment Variables (.env)

Create a .env file at the project root and add:


PORT=5000
MONGO_URI=mongodb://localhost:27017/node_auth_cookie
JWT_SECRET=your_jwt_secret_key
JWT_EXPIRE=1d
COOKIE_EXPIRE=1
  

The COOKIE_EXPIRE represents the number of days after which cookies expire.

✅ Step 4: Setup Express Server

Create server.js in the project root:


// server.js
require("dotenv").config();
const express = require("express");
const mongoose = require("mongoose");
const cookieParser = require("cookie-parser");
const cors = require("cors");

const authRoutes = require("./routes/auth");

const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(cors({
  origin: "http://localhost:3000", // frontend URL
  credentials: true
}));

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

// DB connection
mongoose.connect(process.env.MONGO_URI)
  .then(() => console.log("MongoDB Connected"))
  .catch(err => console.error("Mongo Error:", err));

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

We’ve used cors with credentials so cookies can be sent securely from frontend to backend.

✅ Step 5: Create User Model (models/User.js)


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

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

// Hash password before save
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 password
userSchema.methods.matchPassword = async function(enteredPassword) {
  return await bcrypt.compare(enteredPassword, this.password);
};

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

✅ Step 6: Create JWT Helper

We’ll create a helper to generate JWT and send it as an HTTP-only cookie.


// utils/generateToken.js
const jwt = require("jsonwebtoken");

const sendToken = (user, statusCode, res) => {
  const token = jwt.sign({ id: user._id }, process.env.JWT_SECRET, {
    expiresIn: process.env.JWT_EXPIRE,
  });

  const options = {
    httpOnly: true,
    expires: new Date(Date.now() + process.env.COOKIE_EXPIRE * 24 * 60 * 60 * 1000),
    secure: false, // set true in production (HTTPS)
    sameSite: "lax"
  };

  res.status(statusCode).cookie("token", token, options).json({
    success: true,
    user: { id: user._id, name: user.name, email: user.email },
  });
};

module.exports = sendToken;
  

✅ Step 7: Auth Routes (routes/auth.js)

Now let’s create all main routes — register, login, logout, and profile.


const express = require("express");
const User = require("../models/User");
const jwt = require("jsonwebtoken");
const sendToken = require("../utils/generateToken");
const auth = require("../middleware/auth");

const router = express.Router();

// Register
router.post("/register", async (req, res) => {
  try {
    const { name, email, password } = req.body;
    let user = await User.findOne({ email });
    if (user) return res.status(400).json({ error: "User already exists" });

    user = await User.create({ name, email, password });
    sendToken(user, 201, res);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Login
router.post("/login", async (req, res) => {
  try {
    const { email, password } = req.body;
    const user = await User.findOne({ email });
    if (!user) return res.status(400).json({ error: "Invalid credentials" });

    const isMatch = await user.matchPassword(password);
    if (!isMatch) return res.status(400).json({ error: "Invalid credentials" });

    sendToken(user, 200, res);
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Logout
router.get("/logout", (req, res) => {
  res.cookie("token", "", {
    httpOnly: true,
    expires: new Date(0),
  });
  res.status(200).json({ message: "Logged out successfully" });
});

// Protected Route
router.get("/profile", auth, async (req, res) => {
  const user = await User.findById(req.user.id).select("-password");
  res.json(user);
});

module.exports = router;
  

✅ Step 8: Auth Middleware (middleware/auth.js)


const jwt = require("jsonwebtoken");

module.exports = (req, res, next) => {
  const { token } = req.cookies;
  if (!token) return res.status(401).json({ error: "Not authenticated" });

  try {
    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    req.user = decoded;
    next();
  } catch (error) {
    res.status(401).json({ error: "Token invalid or expired" });
  }
};
  

✅ Step 9: Testing API with Postman

1. Run the server:

npm run dev

2. Use Postman or Thunder Client:

  • POST /api/auth/register → Create new user
  • POST /api/auth/login → Login and receive cookie
  • GET /api/auth/profile → Protected route (requires cookie)
  • GET /api/auth/logout → Logout and clear cookie

Step 3: Install and Use Nodemon for Auto-Reload

When developing Node.js applications, restarting the server manually after every code change can become time-consuming. That’s where Nodemon comes in — it automatically restarts your server whenever you make changes in your project files.

✅ What is Nodemon?

Nodemon is a development tool that monitors your project files for changes and automatically restarts the server. It saves time and improves productivity during backend development.

✅ Step 1: Install Nodemon

You can install Nodemon globally or as a development dependency. Global installation makes it available system-wide, while local installation is limited to your project.


# Option 1: Install globally
npm install -g nodemon

# Option 2: Install locally (recommended)
npm install --save-dev nodemon

✅ Step 2: Update package.json Script

After installing Nodemon, modify your package.json file to include a script that uses Nodemon instead of the default Node command.
This helps you run your server with a single simple command.


{
  "name": "jwt-auth-tutorial",
  "version": "1.0.0",
  "main": "server.js",
  "type": "module",
  "scripts": {
    "start": "node server.js",
    "dev": "nodemon server.js"
  },
  "dependencies": {
    "express": "^4.19.0",
    "mongoose": "^8.1.0",
    "jsonwebtoken": "^9.0.0",
    "cookie-parser": "^1.4.6"
  },
  "devDependencies": {
    "nodemon": "^3.0.2"
  }
}

✅ Step 3: Run Your Server Using Nodemon

Once your script is added, you can run your server in development mode with:


npm run dev

Now, Nodemon will automatically restart your server whenever you save any file (like server.js or your route/controller files). You’ll see a message in the terminal confirming that Nodemon detected the change and restarted the app.

✅ Step 4: Verify It’s Working

Try changing something in your server.js file, such as adding a new console.log line.
When you save it, the terminal should automatically show the restart log:


[nodemon] restarting due to changes...
[nodemon] starting `node server.js`
Server running on port 5000

✅ Step 5: Common Nodemon Tips

  • Use the --ignore flag to skip watching certain files (like logs or uploads).
  • Use nodemon.json config file to define environment variables or ignored folders.
  • To stop Nodemon, press Ctrl + C in your terminal.

{
  "ignore": ["uploads/*", "logs/*"],
  "env": {
    "PORT": 5000,
    "NODE_ENV": "development"
  }
}

With Nodemon configured, your Node.js + Express + MongoDB development becomes much smoother. You can now focus on coding — Nodemon will handle server restarts for you automatically.

✅ Step 10: Secure Cookie Settings

– Always use httpOnly: true to prevent JavaScript access.
– Use secure: true in production (HTTPS).
– Set sameSite: "Strict" for tighter CSRF protection.
– Prefer short expiry (e.g., 1 day).

✅ Step 11: Frontend Integration (Example)

If using React or any frontend, make sure you include:


axios.defaults.withCredentials = true;
axios.post("http://localhost:5000/api/auth/login", { email, password });
  

This ensures cookies are sent automatically with requests.

✅ Step 12: Folder Organization Best Practice

  • Keep reusable functions in utils/
  • Use controllers/ for route logic separation
  • Use middleware/ for access control
  • Use models/ for Mongo schemas
  • Define config/ folder for DB connection logic

✅ Step 13: Error Handling and Validation

For production, add express error handlers and request validation using libraries like express-validator or joi. Example:


if (!email || !password) {
  return res.status(400).json({ error: "Email and password required" });
}
  

✅ Step 14: Why Use Cookies Instead of Local Storage

HTTP-only cookies cannot be accessed via JavaScript → safer against XSS.
– Automatic handling with requests → easier to manage sessions.
– Server can invalidate cookie by clearing it → controlled logout.

✅ Step 15: Final Thoughts

Congratulations 🎉 — you’ve built a complete authentication system using Node.js, Express, MongoDB, and JWT with cookies.

You now understand how to protect private routes, secure cookies, and build real-world backend APIs safely. You can extend this project by adding:

  • Password reset via email
  • Role-based access (admin/user)
  • Refresh tokens for long sessions
  • Two-factor authentication (2FA)

✅ Conclusion

Using JWT with HTTP-only cookies provides a perfect balance between **security and simplicity**.
Your API is now secure against XSS and simple to integrate with any frontend like React, Vue, or Angular.

Leave a Reply

Your email address will not be published. Required fields are marked *