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
--ignoreflag to skip watching certain files (like logs or uploads). - Use
nodemon.jsonconfig 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.
