logo

    How to Build Secure and Scalable Authentication System with Node.js and MongoDB

    Build a secure, scalable authentication system with Node.js, Express, MongoDB, and JWT. Learn best practices for protecting user data and scaling your app.

    Published on
    |
    9 min read
    How to Build Secure and Scalable Authentication System with Node.js and MongoDB

    Understanding Authentication in Web Apps

    Authentication is how we confirm a user’s identity to make sure they are who they say they are. It usually involves entering a username and password, which the system checks against its records. Today, many apps use more secure methods like Multi-factor Authentication or tokens like JSON Web Token. Good authentication protects data, keeps accounts safe and helps prevent unauthorized access.

    In this guide, I’ll walk through building a secure and scalable authentication system using Node.js, Express.js, and MongoDB.

    Step 1: Project Setup

    Begin by creating a new Node.js project. Use TypeScript to ensure strong typing for better maintainability. Install the necessary dependencies:

    Create a new Nodejs Project
    mkdir express-auth-system
    cd express-auth-system
    npm init -y
    Install Dependencies
    npm install express mongoose bcryptjs jsonwebtoken cookie-parser resend
    Install Dev Dependencies
    npm install --save-dev typescript globals nodemon ts-node prettier typescript-eslint dotenv @types/node @types/express @types/bcryptjs

    2. Project Structure

    Organize your project as follows:

    Folder Structure
    src/
    ├── controllers/        # Route handlers
    ├── dtos/               # Data Transfer Objects for validation
    ├── middlewares/        # Protects routes
    ├── models/             # MongoDB schemas and models
    ├── routes/             # Route definitions
    ├── services/           # Business logic
    ├── types/              # Custom types
    ├── app.ts              # Express app setup
    └── server.ts           # Server and database connection

    Step 3: Configure Environment Variables

    Create a .env file in the root directory to store environment variables. Add the following variables:

    .env
    PORT=
    TOKEN_SECRET=
    MONGODB_URI=
    RESEND_API_KEY=
    EMAIL_SENDER=onboarding@resend.dev
    EMAIL_RECIPIENT=
    CLIENT_URL=

    Step 4: Setting Up the App Server

    Create an app.ts file in the src directory to set up the Express app:

    app.ts
    import express from "express"
    import dotenv from "dotenv"
    import cookieParser from "cookie-parser"
     
    import authRoutes from "./routes/authRoutes"
    import userRoutes from "./routes/userRoutes"
     
    dotenv.config()
     
    const app = express()
     
    app.use(express.json())
    app.use(cookieParser())
     
    app.use("/api/auth", authRoutes)
    app.use("/api/users", userRoutes)
     
    export default app

    Step 5: Runnip App and Database connection

    Create a server.ts file in the src directory to connect to the MongoDB database and start the Express server:

    server.ts
    import app from "./app"
    import mongoose from "mongoose"
    import fs from "fs"
     
    const PORT = process.env.PORT || 3000
    const MONGODB_URI = process.env.MONGODB_URI
     
    if (!MONGODB_URI) {
      throw new Error("MONGODB_URI is not defined")
    }
     
    mongoose
      .connect(MONGODB_URI)
      .then(() => {
        const connection = mongoose.connection
        const host = connection.host
     
        console.log("Connected to MongoDB")
     
        const logMessage = `Connected to MongoDB at host: ${host}\n`
        fs.appendFileSync("database.log", logMessage, "utf8")
     
        app.listen(PORT, () => {
          console.log(`Server is running on port ${PORT}`)
        })
      })
      .catch((err) => {
        console.error("Failed to connect to MongoDB", err)
      })

    Step 6: Setting Up Models

    Define a Mongoose schema for the User model with fields like email, name, and password. Make sure to hash passwords before saving them:

    User Model
    import mongoose, { Document, Schema } from "mongoose"
    import bcryptjs from "bcryptjs"
     
    export interface User extends Document {
      email: string
      password: string
      name: string
      lastLogin: Date
      resetPasswordToken: string | undefined
      resetPasswordTokenExpiresAt: Date | undefined
      verificationToken: string | undefined
      verificationTokenExpiresAt: Date | undefined
      isVerified: boolean
      role: "user" | "admin"
      comparePassword(password: string): Promise<boolean>
    }
     
    const userSchema = new Schema<User>(
      {
        email: { type: String, required: true, unique: true },
        password: { type: String, required: true },
        lastLogin: { type: Date, default: null },
        name: { type: String, required: true },
        isVerified: { type: Boolean, default: false },
        role: { type: String, enum: ["user", "admin"], default: "user" },
        resetPasswordToken: String,
        resetPasswordTokenExpiresAt: Date,
        verificationToken: String,
        verificationTokenExpiresAt: Date,
      },
      { timestamps: true },
    )
     
    userSchema.methods.comparePassword = async function (
      password: string,
    ): Promise<boolean> {
      return bcryptjs.compare(password, this.password)
    }
     
    const UserModel = mongoose.model<User>("User", userSchema)
     
    export default UserModel

    Step 7: Implementing Authentication

    Create a routes/authRoutes.ts file to define routes for user authentication:

    authRoutes.ts
    import { Router } from "express"
    import {
      register,
      verifyEmail,
      login,
      logout,
      forgotPassword,
      resetPassword,
    } from "../controllers/authController"
     
    const router = Router()
     
    router.post("/register", register)
    router.post("/verify-email", verifyEmail)
     
    router.post("/login", login)
    router.post("/logout", logout)
     
    router.post("/forgot-password", forgotPassword)
    router.post("/reset-password/:token", resetPassword)
     
    export default router

    Create a controllers/authController.ts file to handle user authentication:

    authController.ts (Register)
    export const register = async (req: Request, res: Response): Promise<void> => {
      try {
        const data: RegisterDTO = req.body
     
        if (!data.email || !data.password || !data.name) {
          throw new Error("Missing required fields")
        }
     
        const user = await AuthService.register(data)
     
        const token = generateToken({ id: user._id, email: user.name })
        setTokenCookie(res, token)
     
        res.status(201).json({
          success: true,
          message: "User created successfully",
          user: user,
        })
      } catch (error: unknown) {
        handleError(res, error, 400, "User registration failed")
      }
    }
    authController.ts (Login)
    export const login = async (req: Request, res: Response): Promise<void> => {
      try {
        const { email, password }: { email: string; password: string } = req.body
     
        if (!email || !password) {
          throw new Error("Missing email or password")
        }
     
        const user = await AuthService.login(email, password)
     
        if (!user) {
          throw new Error("Invalid credentials")
        }
     
        const token = generateToken({ id: user._id, email: user.name })
        setTokenCookie(res, token)
     
        res.status(200).json({
          success: true,
          message: "Login successful",
          user: user,
        })
      } catch (error: unknown) {
        handleError(res, error, 400, "Login failed")
      }
    }
    authController.ts (Verify Email)
    export const verifyEmail = async (
      req: Request,
      res: Response,
    ): Promise<void> => {
      try {
        const { verificationCode }: { verificationCode: string } = req.body
     
        if (!verificationCode) {
          throw new Error("Missing verification code")
        }
     
        const user = await AuthService.verifyEmail(verificationCode)
     
        res.status(200).json({
          success: true,
          message: "Email verified successfully",
          user,
        })
      } catch (error: unknown) {
        handleError(res, error, 400, "Email verification failed")
      }
    }
    authController.ts (Forgot Password)
    export const forgotPassword = async (
      req: Request,
      res: Response,
    ): Promise<void> => {
      const { email } = req.body
      try {
        const user = await AuthService.forgotPassword(email)
     
        res.status(200).json({
          success: true,
          message: "Password reset link sent to your email",
          user,
        })
      } catch (error: unknown) {
        handleError(res, error)
      }
    }
    authController.ts (Reset Password)
    export const resetPassword = async (
      req: Request,
      res: Response,
    ): Promise<void> => {
      try {
        const { token } = req.params
        const { password } = req.body
     
        const user = await AuthService.resetPassword(token, password)
     
        res.status(200).json({
          success: true,
          message: "Password reset successfully",
          user,
        })
      } catch (error: unknown) {
        handleError(res, error)
      }
    }
    authController.ts (Logout)
    export const logout = async (req: Request, res: Response): Promise<void> => {
      res.clearCookie("authToken")
     
      res.status(200).json({
        success: true,
        message: "Logged out successfully",
      })
    }

    Step 8: Implementing User Routes

    Create a routes/userRoutes.ts file to define routes for user-related operations:

    userRoutes.ts
    import { Router } from "express"
    import {
      getAllUsers,
      getUser,
      updateUser,
      deleteUser,
    } from "../controllers/userController"
    import { authMiddleware } from "../middlewares/authMiddleware"
     
    const router = Router()
     
    router.get("/", authMiddleware, getAllUsers)
    router.get("/:id", authMiddleware, getUser)
    router.put("/:id", authMiddleware, updateUser)
    router.delete("/:id", authMiddleware, deleteUser)
     
    export default router

    Step 9: Implementing User Controller

    Create a controllers/userController.ts file to handle user-related operations:

    userController.ts (Get All User)
    export const getAllUsers = async (
      req: Request,
      res: Response,
    ): Promise<void> => {
      try {
        const users = await UserService.getAllUsers()
     
        res.status(200).json({
          success: true,
          message: "Users retrieved successfully",
          users,
        })
      } catch (error: unknown) {
        handleError(res, error)
      }
    }
    userController.ts (Get User)
    export const getUser = async (req: Request, res: Response) => {
      try {
        const user = await UserModel.findById(req.params.id)
     
        if (!user) {
          return res.status(404).json({
            success: false,
            message: "User not found",
          })
        }
     
        res.status(200).json({
          success: true,
          user,
        })
      } catch (error: unknown) {
        handleError(res, error, 500, "Failed to retrieve user")
      }
    }
    userController.ts (Update User)
    export const updateUser = async (
      req: Request,
      res: Response,
    ): Promise<void> => {
      try {
        const userId = req.params.id
        const { email, name, password } = req.body
     
        const user = await UserModel.findById(userId)
     
        if (!user) {
          res.status(404).json({
            message: "User not found",
          })
          return
        }
     
        if (email) {
          user.email = email
        }
        if (name) {
          user.name = name
        }
        if (password) {
          const hashedPassword = await bcryptjs.hash(password, 10)
          user.password = hashedPassword
        }
     
        await user.save()
     
        res.status(200).json({
          message: "User updated successfully",
          user: {
            id: user._id,
            email: user.email,
            name: user.name,
          },
        })
      } catch (error: unknown) {
        handleError(res, error, 500, "Error updating user")
      }
    }
    userController.ts (Delete User)
    export const deleteUser = async (
      req: Request,
      res: Response,
    ): Promise<void> => {
      try {
        const userId = req.params.id
     
        const user = await UserModel.findByIdAndDelete(userId)
     
        if (!user) {
          res.status(404).json({
            message: "User not found",
          })
          return
        }
     
        res.status(200).json({
          message: "User deleted successfully",
          user: {
            id: user._id,
            email: user.email,
            name: user.name,
          },
        })
      } catch (error: unknown) {
        handleError(res, error, 500, "Error deleting user")
      }
    }

    Step 10: Implementing Authorization

    Create a middlewares/authMiddleware.ts file to protect routes that require authentication:

    authMiddleware.ts
    import { Request, Response, NextFunction } from "express"
    import jwt, { JwtPayload } from "jsonwebtoken"
    import { handleError } from "../utils/errorHandler"
     
    interface DecodedToken {
      id: string
    }
     
    export const authMiddleware = (
      req: Request,
      res: Response,
      next: NextFunction,
    ) => {
      const token =
        req.cookies.authToken || req.headers.authorization?.split(" ")[1]
     
      if (!token) {
        return res.status(401).json({ message: "Unauthorized: No token provided" })
      }
     
      try {
        const decoded = jwt.verify(
          token,
          process.env.TOKEN_SECRET || "your-secret-key",
        ) as JwtPayload | DecodedToken
     
        if (typeof decoded === "object" && "id" in decoded) {
          req.body.userId = decoded.id
     
          next()
        } else {
          throw new Error("Invalid token structure")
        }
      } catch (error) {
        handleError(res, error, 401, "Unauthorized: Invalid token")
      }
    }

    Step Last: Enhancements and Best Practices

    • Testing: Write unit and integration tests to ensure code quality.
    • Rate Limiting: Protect your endpoints from abuse.
    • HTTPS: Use HTTPS to encrypt data in transit.
    • Monitoring: Implement logging and monitoring to detect and respond to issues.