This tutorial teaches you how to build a secure Node.js and MongoDB backend with JWT Authentication and Authorization.
Lukas
Created: January 15, 2025
Updated: February 17, 2025
Our mission is to make the Internet a safer place, and the new login standard passkeys provides a superior solution to achieve that. That's why we want to help you understand passkeys and its characteristics better.
In this tutorial, we will build a secure backend application using Node.js and MongoDB that supports user authentication (registration and login) and authorization with JSON Web Tokens (JWT). By the end of this guide, you will understand how to:
This comprehensive guide ensures you have all the necessary information to implement the application independently, following the latest best practices and leveraging modern technologies.
JSON Web Tokens (JWT) are a compact and self-contained way for securely transmitting information between parties as a JSON object. Unlike session-based authentication, where session data is stored on the server, JWTs are stored on the client side (e.g., in Local Storage for browsers) and eliminate the need for additional backend sessions or authentication modules for different clients.
A JWT consists of three parts:
The structure follows the header.payload.signature
format. Clients typically attach JWTs in the Authorization
header with the Bearer
prefix:
Authorization: Bearer <token>
See the sign-up, login and access resource sequence charts:
There's also the necessity to refresh tokens if the old one is expired:
Here's the directory structure for our Node.js, Express, and MongoDB application:
node-js-jwt-auth-mongodb/ ├── app/ │ ├── config/ │ │ ├── auth.config.js │ │ └── db.config.js │ ├── controllers/ │ │ ├── auth.controller.js │ │ └── user.controller.js │ ├── middlewares/ │ │ ├── authJwt.js │ │ └── verifySignUp.js │ ├── models/ │ │ ├── index.js │ │ ├── role.model.js │ │ └── user.model.js │ └── routes/ │ ├── auth.routes.js │ └── user.routes.js ├── package.json └── server.js
Here is the overview of the Node.js Express app:
First, create a new directory for your project and initialize a Node.js application:
mkdir node-js-jwt-auth-mongodb cd node-js-jwt-auth-mongodb npm init -y
Update your package.json
to use ESModules by adding "type": "module"
:
Install the required dependencies:
npm install express cors bcryptjs jsonwebtoken mongoose
Create a server.js
file in the root directory:
// server.js import express from "express"; import cors from "cors"; import db from "./app/models/index.js"; import authRoutes from "./app/routes/auth.routes.js"; import userRoutes from "./app/routes/user.routes.js"; const app = express(); // Middleware configuration const corsOptions = { origin: "http://localhost:8081" }; app.use(cors(corsOptions)); app.use(express.json()); app.use(express.urlencoded({extended: true})); // Simple route for testing app.get("/", (req, res) => { res.json({message: "Welcome to the Node.js JWT Authentication application."}); }); // Routes app.use("/api/auth", authRoutes); app.use("/api/test", userRoutes); // Set port and start server const PORT = process.env.PORT || 8080; // Connect to MongoDB and start the server db.mongoose .connect(`mongodb://${db.config.HOST}:${db.config.PORT}/${db.config.DB}`) .then(() => { console.log("Successfully connected to MongoDB."); // Initialize roles in the database initial(); app.listen(PORT, () => { console.log(`Server is running on port ${PORT}.`); }); }) .catch((err) => { console.error("Connection error:", err); process.exit(); }); // Initial function to populate roles function initial() { db.Role.estimatedDocumentCount() .then((count) => { if (count === 0) { return Promise.all([ new db.Role({name: "user"}).save(), new db.Role({name: "admin"}).save(), new db.Role({name: "moderator"}).save() ]); } }) .then((roles) => { if (roles) { console.log("Added 'user', 'admin', and 'moderator' to roles collection."); } }) .catch((err) => { console.error("Error initializing roles:", err); }); }
Create a configuration file for MongoDB connection parameters:
// app/config/db.config.js export default { HOST: 'localhost', PORT: 27017, DB: 'node_js_jwt_auth_db', };
// app/models/user.model.js import mongoose from 'mongoose'; const userSchema = new mongoose.Schema( { username: { type: String, required: true, unique: true, trim: true, }, email: { type: String, required: true, unique: true, trim: true, lowercase: true, }, password: { type: String, required: true, minlength: 6, }, roles: [ { type: mongoose.Schema.Types.ObjectId, ref: 'Role', }, ], }, { timestamps: true } ); const User = mongoose.model('User', userSchema); export default User;
// app/models/role.model.js import mongoose from 'mongoose'; const roleSchema = new mongoose.Schema({ name: { type: String, required: true, unique: true, }, }); const Role = mongoose.model('Role', roleSchema); export default Role;
// app/models/index.js import mongoose from 'mongoose'; import dbConfig from '../config/db.config.js'; import User from './user.model.js'; import Role from './role.model.js'; const db = {}; db.mongoose = mongoose; db.User = User; db.Role = Role; db.ROLES = ['user', 'admin', 'moderator']; db.config = dbConfig; export default db;
Mongoose is initialized in the server.js
file, where we connect to the MongoDB database and initialize roles if they don't exist.
Create a configuration file for authentication-related settings:
// app/config/auth.config.js export default { secret: 'your-secret-key', // Replace with your own secret key };
Security Note: Ensure that the secret key is stored securely, preferably using environment variables or a secrets manager in a production environment.
This middleware checks for duplicate usernames or emails and verifies the existence of roles.
// app/middlewares/verifySignUp.js import db from '../models/index.js'; const ROLES = db.ROLES; const User = db.User; const checkDuplicateUsernameOrEmail = async (req, res, next) => { try { // Check if username exists const userByUsername = await User.findOne({ username: req.body.username }); if (userByUsername) { return res.status(400).json({ message: 'Failed! Username is already in use!' }); } // Check if email exists const userByEmail = await User.findOne({ email: req.body.email }); if (userByEmail) { return res.status(400).json({ message: 'Failed! Email is already in use!' }); } next(); } catch (err) { res.status(500).json({ message: err.message }); } }; const checkRolesExisted = (req, res, next) => { if (req.body.roles) { const invalidRoles = req.body.roles.filter((role) => !ROLES.includes(role)); if (invalidRoles.length > 0) { return res.status(400).json({ message: `Failed! Roles [${invalidRoles.join(', ')}] do not exist!`, }); } } next(); }; const verifySignUp = { checkDuplicateUsernameOrEmail, checkRolesExisted, }; export default verifySignUp;
This middleware verifies the presence and validity of JWTs and checks user roles for authorization.
// app/middlewares/authJwt.js import jwt from 'jsonwebtoken'; import config from '../config/auth.config.js'; import db from '../models/index.js'; const User = db.User; const Role = db.Role; const verifyToken = async (req, res, next) => { let token = req.headers['x-access-token'] || req.headers['authorization']; if (!token) { return res.status(403).json({ message: 'No token provided!' }); } // Remove 'Bearer ' prefix if present if (token.startsWith('Bearer ')) { token = token.slice(7, token.length); } try { const decoded = jwt.verify(token, config.secret); req.userId = decoded.id; // Fetch user details const user = await User.findById(req.userId); if (!user) { return res.status(404).json({ message: 'User not found!' }); } req.user = user; next(); } catch (err) { return res.status(401).json({ message: 'Unauthorized!' }); } }; const isAdmin = async (req, res, next) => { try { const user = req.user; const roles = await Role.find({ _id: { $in: user.roles } }); const hasAdminRole = roles.some((role) => role.name === 'admin'); if (!hasAdminRole) { return res.status(403).json({ message: 'Require Admin Role!' }); } next(); } catch (err) { res.status(500).json({ message: err.message }); } }; const isModerator = async (req, res, next) => { try { const user = req.user; const roles = await Role.find({ _id: { $in: user.roles } }); const hasModeratorRole = roles.some((role) => role.name === 'moderator'); if (!hasModeratorRole) { return res.status(403).json({ message: 'Require Moderator Role!' }); } next(); } catch (err) { res.status(500).json({ message: err.message }); } }; const authJwt = { verifyToken, isAdmin, isModerator, }; export default authJwt;
Combining all middleware functions for easier import.
// app/middlewares/index.js import authJwt from './authJwt.js'; import verifySignUp from './verifySignUp.js'; export { authJwt, verifySignUp };
Handles user registration (signup
) and login (signin
).
// app/controllers/auth.controller.js import config from '../config/auth.config.js'; import db from '../models/index.js'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; const User = db.User; const Role = db.Role; export const signup = async (req, res) => { try { // Create a new user const user = new User({ username: req.body.username, email: req.body.email, password: bcrypt.hashSync(req.body.password, 8), }); const role = await Role.findOne({name: 'user'}); user.roles = [role._id]; // Save user to the database await user.save(); res.status(201).json({message: 'User was registered successfully!'}); } catch (err) { res.status(500).json({message: err.message}); } }; export const signin = async (req, res) => { try { // Find user by username const user = await User.findOne({username: req.body.username}).populate('roles', '-__v'); if (!user) { return res.status(404).json({message: 'User Not found.'}); } // Validate password const passwordIsValid = bcrypt.compareSync(req.body.password, user.password); if (!passwordIsValid) { return res.status(401).json({ accessToken: null, message: 'Invalid Password!', }); } // Generate JWT const token = jwt.sign({id: user.id}, config.secret, { algorithm: 'HS256', expiresIn: 86400, // 24 hours }); // Extract user roles const authorities = user.roles.map((role) => `ROLE_${role.name.toUpperCase()}`); res.status(200).json({ id: user._id, username: user.username, email: user.email, roles: authorities, accessToken: token, }); } catch (err) { res.status(500).json({message: err.message}); } };
Handles access to protected resources based on user roles.
// app/controllers/user.controller.js export const allAccess = (req, res) => { res.status(200).send('Public Content.'); }; export const userBoard = (req, res) => { res.status(200).send('User Content.'); }; export const moderatorBoard = (req, res) => { res.status(200).send('Moderator Content.'); }; export const adminBoard = (req, res) => { res.status(200).send('Admin Content.'); };
Define routes for user signup and signin.
// app/routes/auth.routes.js import express from 'express'; import { signup, signin } from '../controllers/auth.controller.js'; import { verifySignUp } from '../middlewares/index.js'; const router = express.Router(); // Signup route router.post( '/signup', [verifySignUp.checkDuplicateUsernameOrEmail, verifySignUp.checkRolesExisted], signup ); // Signin route router.post('/signin', signin); export default router;
Define routes for accessing protected resources based on user roles.
// app/routes/user.routes.js import express from "express"; import { adminBoard, allAccess, moderatorBoard, userBoard } from "../controllers/user.controller.js"; import { authJwt } from "../middlewares/index.js"; const router = express.Router(); // Public route router.get("/all", allAccess); // User route (any authenticated user) router.get("/user", [authJwt.verifyToken], userBoard); // Moderator route router.get("/mod", [authJwt.verifyToken, authJwt.isModerator], moderatorBoard); // Admin route router.get("/admin", [authJwt.verifyToken, authJwt.isAdmin], adminBoard); export default router;
Run the application using the start script defined in package.json
:
npm start
You should see console logs indicating that the server is running and connected to MongoDB:
Successfully connected to MongoDB. Added 'user', 'admin', and 'moderator' to roles collection. Server is running on port 8080.
In case you don't have a MongoDB instance to use for testing available, you can create a temporary one using the following docker command:
docker run --rm -p 27017:27017 mongo
Use tools like Postman or cURL to interact with the API.
Endpoint: POST /api/auth/signup
Body:
{ "username": "john_doe", "email": "john@example.com", "password": "password123" }
Response:
{ "message": "User was registered successfully!" }
Note: All users are assigned the user
role by default. More privileged roles should not be obtained through registration,
but directly through database access.
Endpoint: POST /api/auth/signin
Body:
{ "username": "john_doe", "password": "password123" }
Response:
{ "id": "user_id", "username": "john_doe", "email": "john@example.com", "roles": ["ROLE_USER"], "accessToken": "jwt_token" }
Endpoint: GET /api/test/all
Response:
Public Content.
Endpoint: GET /api/test/user
Headers:
Authorization: Bearer <jwt_token>
Response:
User Content.
Endpoint: GET /api/test/admin
Headers:
Authorization: Bearer <jwt_token>
Response:
{ "message": "Require Admin Role!" }
Note: Only users with the admin
role can access this endpoint.
Endpoint: GET /api/test/mod
Headers:
Authorization: Bearer <jwt_token>
Response:
{ "message": "Require Moderator Role!" }
Note: Only users with the admin
role can access this endpoint.
Building a secure authentication and authorization system involves several critical factors:
Ensure that your JWT secret key (auth.config.js
) is kept secure. In production environments, use environment variables or a secure secrets manager to store sensitive information.
expiresIn: 86400
seconds) to limit the window of opportunity for token misuse.Implement RBAC to restrict access to resources based on user roles. This ensures that users can only access what they are permitted to.
Provide meaningful error messages without exposing sensitive information. Always handle errors gracefully to prevent potential leaks.
Always use HTTPS in production to encrypt data in transit, protecting tokens and sensitive information from interception.
Implement rate limiting to protect against brute-force attacks and denial-of-service (DoS) attempts.
Congratulations! You've successfully built a secure backend application using Node.js and MongoDB with robust user authentication and authorization using JWT. This setup ensures that your application can handle user registration, login, and role-based access control efficiently and securely.
Happy coding!
Table of Contents
Enjoyed this read?
🤝 Join our Passkeys Community
Share passkeys implementation tips and get support to free the world from passwords.
🚀 Subscribe to Substack
Get the latest news, strategies, and insights about passkeys sent straight to your inbox.
Related Articles