Back to Blog
NodeJS

Build a Secure Node.js Authentication System from Scratch

10/5/2025
5 min read
Build a Secure Node.js Authentication System from Scratch

A comprehensive, step-by-step guide to building a secure Node.js authentication system with JWT, bcrypt, and MongoDB. Learn best practices for password hashing, middleware, and protecting routes. Perfect for backend developers.

Build a Secure Node.js Authentication System from Scratch

Build a Secure Node.js Authentication System from Scratch

Building a Rock-Solid Node.js Authentication System from Scratch

Let's be honest. When you're building a web application, one of the first and most critical hurdles you'll face is authentication. Who is this user? Are they who they claim to be? How do I keep them logged in? And, most importantly, how do I do all of this without creating a massive security vulnerability?

It's a daunting task, but it's also a rite of passage for every backend developer. Relying on third-party services like Auth0 or Firebase is great, but truly understanding what happens under the hood is invaluable. It makes you a better, more confident developer.

In this comprehensive guide, we're going to roll up our sleeves and build a complete, secure, and production-ready Node.js authentication system from the ground up. We'll use the modern stack of Express.js, MongoDB, JWT (JSON Web Tokens), and bcrypt. By the end, you'll not only have a working system but a deep understanding of every component involved.

What Exactly Are We Building?

Before we dive into code, let's outline our goals. Our authentication system will allow users to:

  1. Register for a new account.

  2. Log in to an existing account.

  3. Access protected routes that only logged-in users can see.

  4. Log out (or rather, invalidate their token on the client side).

The core of our security will rely on two main pillars:

  • bcrypt: For hashing passwords. We will never store plain text passwords.

  • JWT (JSON Web Tokens): A stateless way to create access tokens that prove a user's identity.

Prerequisites

To follow along, you should have:

  • Node.js and npm installed on your machine.

  • A basic understanding of JavaScript, Node.js, and Express.

  • MongoDB installed locally or an account with MongoDB Atlas for a cloud database.

  • Postman or Thunder Client (for VS Code) to test our API endpoints.

Feeling a bit rusty on the fundamentals? Don't worry. To learn professional software development courses such as Python Programming, Full Stack Development, and MERN Stack, visit and enroll today at codercrafter.in. Our structured courses can help you build a strong foundation.

Step 1: Setting Up the Project

Let's start by creating a new project and installing all the necessary dependencies.

Open your terminal and run:

bash

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

Now, let's install the packages we need:

bash

npm install express mongoose bcryptjs jsonwebtoken dotenv cors

And for development dependencies (like Nodemon to auto-restart our server):

bash

npm install --save-dev nodemon
  • express: Our web framework for Node.js.

  • mongoose: An ODM (Object Data Modeling) library for MongoDB and Node.js.

  • bcryptjs: A library to hash passwords. (We use bcryptjs over bcrypt for easier installation on Windows).

  • jsonwebtoken: To create and verify JSON Web Tokens.

  • dotenv: To load environment variables from a .env file.

  • cors: To enable Cross-Origin Resource Sharing, essential when a frontend talks to a backend on a different port.

Now, let's create our project structure. It will look something like this:

text

node-auth-tutorial/
│   .env
│   .gitignore
│   server.js
│   package.json
│
├───config
│       database.js
│
├───models
│       User.js
│
├───routes
│       authRoutes.js
│
└───middleware
        authMiddleware.js

Create these files and folders. We'll populate them one by one.

Step 2: The Foundation - Server and Database Connection

First, let's set up our basic Express server and connect to MongoDB.

Create a .env file in the root to store our sensitive information:

env

# .env
PORT=5000
MONGODB_URI=mongodb://localhost:27017/node_auth
JWT_SECRET=mySuperSecretJWTKeyThatIsVeryLongAndSecure

Now, let's create the database connection configuration.

config/database.js

javascript

const mongoose = require('mongoose');

const connectDB = async () => {
  try {
    const conn = await mongoose.connect(process.env.MONGODB_URI);
    console.log(`MongoDB Connected: ${conn.connection.host}`);
  } catch (error) {
    console.error(error.message);
    process.exit(1); // Exit process with failure
  }
};

module.exports = connectDB;

Next, our main server file.

server.js

javascript

const express = require('express');
const cors = require('cors');
const dotenv = require('dotenv').config();
const connectDB = require('./config/database');

// Connect to database
connectDB();

const app = express();
const PORT = process.env.PORT || 5000;

// Middleware
app.use(cors());
app.use(express.json()); // to parse JSON bodies
app.use(express.urlencoded({ extended: false })); // to parse URL-encoded bodies

// Basic route for testing
app.get('/', (req, res) => {
  res.json({ message: 'Hello from Node.js Auth API!' });
});

// Auth Routes
app.use('/api/auth', require('./routes/authRoutes'));

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

Finally, let's update our package.json to have a start and dev script.

package.json (scripts section)

json

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

Now, you can run npm run dev and you should see "Server running on port 5000" and "MongoDB Connected" in your console. Our foundation is solid!

Step 3: Defining the User Model

The User Model is a blueprint for how user data will be stored in our MongoDB database. This is where we define the crucial rule: passwords must be hashed.

models/User.js

javascript

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

const userSchema = mongoose.Schema(
  {
    name: {
      type: String,
      required: [true, 'Please add a name'],
    },
    email: {
      type: String,
      required: [true, 'Please add an email'],
      unique: true,
      trim: true,
      match: [
        /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/,
        'Please enter a valid email',
      ],
    },
    password: {
      type: String,
      required: [true, 'Please add a password'],
      minLength: [6, 'Password must be at least 6 characters'],
      // maxLength: [23, "Password must not be more than 23 characters"],
    },
  },
  {
    timestamps: true, // This adds `createdAt` and `updatedAt` fields automatically
  }
);

//   Encrypt password before saving to DB
userSchema.pre('save', async function (next) {
  if (!this.isModified('password')) {
    return next();
  }

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

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

Let's break down the important parts:

  1. Validation: We use Mongoose's built-in validators like required, unique, and match (for regex email validation) to ensure data quality.

  2. Pre-save Middleware (userSchema.pre('save')): This function runs just before a user document is saved to the database. Here, we check if the password field has been modified. If it has, we generate a "salt" (a random string) and use it to hash the plain text password using bcrypt.hash. The original password is then replaced with this secure hash.

This means that even if our database is compromised, attackers won't have the actual passwords.

Step 4: The Heart of the System - Authentication Routes

This is where the magic happens. We'll create two main routes: /register and /login.

routes/authRoutes.js

javascript

const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const User = require('../models/User');

const router = express.Router();

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

    // Validation
    if (!name || !email || !password) {
      return res.status(400).json({ message: 'Please fill in all fields' });
    }
    if (password.length < 6) {
      return res.status(400).json({ message: 'Password must be at least 6 characters' });
    }

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

    // Create new user - the pre('save') middleware in the model will hash the password
    const user = await User.create({
      name,
      email,
      password, // This is the plain text password, which will be hashed by the model
    });

    // Generate JWT Token
    const token = generateToken(user._id);

    // Send back the user and token (but not the password)
    res.status(201).json({
      _id: user._id,
      name: user.name,
      email: user.email,
      token,
    });
  } catch (error) {
    res.status(500).json({ message: 'Something went wrong', error: error.message });
  }
});

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

    // Validation
    if (!email || !password) {
      return res.status(400).json({ message: 'Please fill in all fields' });
    }

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

    // Check if password is correct
    const passwordIsCorrect = await bcrypt.compare(password, user.password);

    if (passwordIsCorrect) {
      // Generate JWT Token
      const token = generateToken(user._id);

      res.json({
        _id: user._id,
        name: user.name,
        email: user.email,
        token,
      });
    } else {
      res.status(400).json({ message: 'Invalid credentials' });
    }
  } catch (error) {
    res.status(500).json({ message: 'Something went wrong', error: error.message });
  }
});

// Generate JWT Token (a helper function)
const generateToken = (id) => {
  return jwt.sign({ id }, process.env.JWT_SECRET, {
    expiresIn: '30d', // Token expires in 30 days
  });
};

module.exports = router;

Deep Dive into the Login Flow:

The login process is critical. Here's what happens step-by-step:

  1. Request: The user sends their email and password.

  2. Find User: We use User.findOne({ email }) to find a user in the database with that email.

  3. Compare Passwords: We never decrypt the hashed password. Instead, we use bcrypt.compare(plainTextPassword, hashedPasswordFromDB). bcrypt hashes the provided plain text password using the same original salt and compares the resulting hash with the one in the database. If they match, the password is correct.

  4. Issue Token: If everything is valid, we generate a JWT. The token is digitally signed with our secret key (JWT_SECRET) and contains a payload—in this case, just the user's ID ({ id }). This token is the key the client will use to prove their identity in subsequent requests.

Step 5: Protecting Routes with Middleware

We have a way to get a token. Now, how do we use it to protect routes? The answer is Express Middleware.

Middleware is a function that has access to the request (req), response (res), and the next function in the application’s request-response cycle. We'll create a middleware that verifies the JWT.

middleware/authMiddleware.js

javascript

const jwt = require('jsonwebtoken');
const User = require('../models/User');

const protect = async (req, res, next) => {
  try {
    let token;

    // Check for token in the Authorization header
    if (req.headers.authorization && req.headers.authorization.startsWith('Bearer')) {
      // Format is: "Bearer <actual_token>"
      token = req.headers.authorization.split(' ')[1];
    }

    // Check if token exists
    if (!token) {
      return res.status(401).json({ message: 'Not authorized, no token' });
    }

    // Verify the token
    const verified = jwt.verify(token, process.env.JWT_SECRET);

    // Get user from the token (excluding the password) and attach it to the request object
    const user = await User.findById(verified.id).select('-password');
    if (!user) {
      return res.status(401).json({ message: 'Not authorized, user not found' });
    }

    req.user = user; // The request object now has a `user` property
    next(); // Proceed to the next middleware/route handler
  } catch (error) {
    console.error(error);
    res.status(401).json({ message: 'Not authorized, token failed' });
  }
};

module.exports = { protect };

Now, let's create a protected route to test this. Add this to your authRoutes.js file.

Inside routes/authRoutes.js (add this before module.exports)

javascript

const { protect } = require('../middleware/authMiddleware');

// @desc    Get logged-in user data
// @route   GET /api/auth/me
// @access  Private (Protected)
router.get('/me', protect, async (req, res) => {
  try {
    // req.user was set by the protect middleware
    const user = {
      id: req.user._id,
      name: req.user.name,
      email: req.user.email,
    };
    res.status(200).json(user);
  } catch (error) {
    res.status(500).json({ message: 'Something went wrong' });
  }
});

How it works:

  1. A client requests the /api/auth/me endpoint.

  2. The protect middleware runs first.

  3. It looks for the token in the Authorization header.

  4. It uses jwt.verify to check the token's validity and extract the user ID.

  5. It fetches the user from the database and attaches it to req.user.

  6. If all checks pass, next() is called, and the request proceeds to the actual route handler, which simply sends back the user's data.

Step 6: Testing the Entire Flow

It's time to see our hard work in action! Open Postman or your API client of choice.

1. Register a New User

  • Method: POST

  • URL: http://localhost:5000/api/auth/register

  • Body (raw JSON):

    json

    {
      "name": "John Doe",
      "email": "john.doe@example.com",
      "password": "123456"
    }
  • Response: You should get a 201 Created status with the user details and a token.

2. Login

  • Method: POST

  • URL: http://localhost:5000/api/auth/login

  • Body (raw JSON):

    json

    {
      "email": "john.doe@example.com",
      "password": "123456"
    }
  • Response: You should get a 200 OK status with the user details and a new token.

3. Access Protected Route

  • Method: GET

  • URL: http://localhost:5000/api/auth/me

  • Headers:

    • Key: Authorization

    • Value: Bearer <paste_the_token_you_received_here>

  • Response: You should get a 200 OK status with the user's data. If you remove the header or use a fake token, you'll get a 401 Unauthorized error.

Congratulations! You've just built a fully functional authentication system.

Best Practices & Security Considerations for Production

What we have is a great start, but for a production application, you need to go further.

  1. Use Environment Variables for Secrets: We're already doing this with JWT_SECRET and MONGODB_URI. Never hardcode secrets.

  2. Strong JWT Secret: Your JWT_SECRET should be a long, complex, and random string.

  3. Password Strength: Implement stronger server-side validation for passwords (e.g., requiring uppercase, lowercase, numbers, and symbols).

  4. Rate Limiting: Use a library like express-rate-limit to prevent brute-force attacks on login and register endpoints.

  5. Helmet.js: Use helmet to set various HTTP headers for added security.

  6. CORS Configuration: Instead of allowing all origins (app.use(cors())), configure it to only allow your frontend's domain.

  7. HTTPS Everywhere: In production, ensure your server uses HTTPS to encrypt data in transit.

  8. Token Storage on Client: Store JWTs securely in httpOnly cookies (instead of local storage) to mitigate XSS attacks. This is a more advanced but highly recommended pattern.

  9. Refresh Tokens: Implement a refresh token rotation strategy to allow users to stay logged in without compromising security by having short-lived access tokens.

Building secure systems is a complex and rewarding challenge. If you want to master these advanced concepts and build real-world, scalable applications, our MERN Stack course at codercrafter.in dives deep into enterprise-level security and architecture.

Frequently Asked Questions (FAQs)

Q1: Why use JWT over Sessions?
JWTs are stateless. The server doesn't need to keep a record of who is logged in; it simply verifies the token's signature. This makes it easier to scale across multiple servers. Sessions are stateful and require a shared storage (like Redis) across servers.

Q2: Where should I store the JWT on the client?
While many tutorials use localStorage, it's vulnerable to XSS attacks. The most secure method is to store it in an httpOnly cookie, which is inaccessible to JavaScript. However, this requires a different setup on the backend (handling SameSite and Secure flags).

Q3: How do I log out with JWT?
Since JWTs are stateless, you cannot invalidate a token on the server upon logout. The standard practice is to simply delete the token from the client-side (e.g., remove it from localStorage or your state management). For immediate invalidation, you would need to maintain a token blacklist on the server, which adds state and complexity.

Q4: What is password hashing and salting?
Hashing is a one-way function that converts a password into a fixed-length string. A "salt" is a random value added to the password before hashing. This ensures that even if two users have the same password, their hashes will be different, and it protects against precomputed "rainbow table" attacks.

Q5: Is MongoDB the best choice for authentication?
It's a great choice for its flexibility and ease of use with Node.js. However, a relational database like PostgreSQL is also an excellent option, especially if your data relationships become more complex. The principles of hashing and token-based auth remain the same.

Conclusion

You've made it! You've successfully built a secure, from-scratch Node.js authentication system. Let's recap what we've accomplished:

  • Set up an Express server with MongoDB using Mongoose.

  • Designed a secure User model that hashes passwords automatically.

  • Implemented user registration and login endpoints.

  • Used JWT to create stateless authentication tokens.

  • Built a middleware to protect sensitive API routes.

  • Discussed crucial security best practices for taking this to production.

Understanding authentication is a cornerstone of backend development. It's a complex topic, but by building it yourself, you've gained insights that will serve you in every future project.

This tutorial is just the beginning. The world of backend development is vast, encompassing everything from RESTful API design and database optimization to containerization with Docker and cloud deployment. To learn professional software development courses such as Python Programming, Full Stack Development, and MERN Stack, visit and enroll today at codercrafter.in. We provide the structured path, expert mentorship, and real-world projects to transform you into a job-ready developer.

Related Articles

Call UsWhatsApp