Node.jsMongoDBTutorial

Build an Authentication System with Node, Express, and MongoDB

Register and log in users with hashed passwords and JWTs, the approach that worked for me, shared step by step. My first tutorial, from 2019.

Temitayo Adeeyo5 min read

From the archive

An early tutorial from my first years of writing in public, preserved as part of the record. The engineer who wrote it was still learning out loud. Some practices here are not how I would build today, and that's rather the point.

Over the years I've settled into an approach for registering and logging in users that has served me well, and I'm delighted to share the decisions that worked for me. I hope to get comments and insight from you to help me learn and become better, we are all learning.1

What we want to do

I'll assume you have an existing understanding of JavaScript, Node.js, Express, and MongoDB. We want to build a platform where a user can register and then log in to their account.

Main dependencies.

  1. Node.js installed
  2. MongoDB
  3. Babel (so we can use ES module imports)
  4. JSON Web Token
  5. bcrypt

Project structure and configuration

The src folder contains the source files of the project, with the auth logic split into register and login folders, each holding its own model, controller, and route files.

A config folder specifies the variables the app needs (see the config package for how environment variables are loaded).

config/default.js
'use strict';
require('dotenv').config();
 
module.exports = {
  app: {
    name: 'authenticationsystem',
    baseUrl: 'http://localhost:',
    port: process.env.PORT,
    expiresIn: 86400,
  },
  api: {
    prefix: '^/api/v[1-9]',
    version: [1],
  },
  database: {
    url: process.env.DB_URL,
  },
};

The server

The server.js file is where we start our development server, connect to MongoDB, and mount the routes.

server.js
'use strict';
import express from 'express';
import bodyParser from 'body-parser';
import mongoose from 'mongoose';
import config from 'config';
import register_route from './src/api/auth/register/register.route';
import login_route from './src/api/auth/login/login.route';
 
const port = process.env.PORT || config.get('app.port');
const prefix = config.get('api.prefix');
const db = config.get('database.url');
const app = express();
 
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));
app.use(prefix, register_route);
app.use(prefix, login_route);
 
mongoose
  .connect(db, { useNewUrlParser: true })
  .then(() => console.log('connected to mongoDB', db))
  .catch((err) => console.log('error mongodb', err));
 
app.listen(port, () => console.log('listening on port', port));

On a successful start you should see connected to mongoDB and listening on port logged to your console.

The register model

Now that the server is up and running, let's write some code. Create register.model.js.

src/api/auth/register/register.model.js
import mongoose from 'mongoose';
 
const Schema = mongoose.Schema;
 
const userSchema = new Schema({
  first_name: { type: String, required: true },
  last_name: { type: String, required: true },
  email: { type: String, required: true },
  password: { type: String, required: true },
});
 
export const register = mongoose.model('user', userSchema, 'register');
module.exports = register;

The register controller

Create register.controller.js. The important moves here. Check for an existing email before creating the account, and never store the password as plain text, bcrypt hashes it first.

src/api/auth/register/register.controller.js
import RegisterModel from './register.model';
import bcrypt from 'bcrypt';
 
exports.create = async (req, res, next) => {
  // check if the email provided already exists in the DB
  const email_exist = await RegisterModel.findOne({ email: req.body.email });
  if (email_exist) {
    return res.status(409).json({ status: false, message: 'email already exists' });
  }
 
  const new_user = new RegisterModel(req.body);
 
  bcrypt.genSalt(10, (err, salt) => {
    // hash the user password, never store plain text // [!code highlight]
    bcrypt.hash(new_user.password, salt, (err, hash) => {
      new_user.password = hash;
      new_user
        .save()
        .then((saved) => {
          if (!saved) {
            return res
              .status(400)
              .json({ status: false, message: 'an error occurred, please try again' });
          }
          return res
            .status(201)
            .json({ status: true, message: 'registration successful', data: saved });
        })
        .catch((error) => {
          return res.status(500).json({ status: false, message: 'an error occurred', error });
        });
    });
  });
};
 
// list all registered users
exports.find = (req, res, next) => {
  RegisterModel.find()
    .sort({ createdAt: -1 })
    .then((response) => {
      return res.status(200).json({ status: true, data: response });
    })
    .catch((error) => {
      return res.status(500).json({ status: false, error });
    });
};

And here is the route to expose it.

src/api/auth/register/register.route.js
import express from 'express';
import Register_controller from './register.controller';
 
const router = express.Router();
 
router.post('/user', Register_controller.create);
router.get('/users', Register_controller.find);
router.get('/user/:id', Register_controller.findOne);
 
module.exports = router;

The login controller

We're still on track. Now that we can register a user, let's create the login controller and route. Login does three things. Find the user by email, compare the supplied password against the stored hash, and, only on a match, sign a JWT the client can use for subsequent requests.

src/api/auth/login/login.controller.js
import RegisterModel from '../register/register.model';
import jwt from 'jsonwebtoken';
import bcrypt from 'bcrypt';
 
exports.login = (req, res, next) => {
  const email = req.body.email;
  const password = req.body.password;
 
  RegisterModel.findOne({ email: email }).then((user) => {
    if (!user) {
      return res
        .status(400)
        .json({ status: false, message: 'no user with the provided email address' });
    }
 
    bcrypt.compare(password, user.password).then((isMatch) => {
      if (!isMatch) {
        return res
          .status(400)
          .json({ status: false, message: 'incorrect password, please check and try again' });
      }
 
      const payload = { id: user.id };
      jwt.sign(payload, process.env.JWT_SECRET, { expiresIn: '365d' }, (error, token) => { // [!code highlight]
        return res.status(200).json({
          status: true,
          message: 'login successful',
          data: { user: user, token: 'Bearer ' + token },
        });
      });
    });
  });
};
src/api/auth/login/login.route.js
import express from 'express';
import Login_controller from './login.controller';
 
const router = express.Router();
 
router.post('/login', Login_controller.login);
 
module.exports = router;

Testing with Postman

Now that we're done, let's test what we've built.

Create a user. POST /api/v1/user with a JSON body containing first_name, last_name, email, and password. You should get back a 201 with the saved user and a hashed password in place of the one you sent.

Log in. POST /api/v1/login with the email and password. A correct pair returns 200 with the user object and a Bearer token; a wrong password returns 400.

And that's it, we built an authentication system with Node, Express, and MongoDB. Thank you for your patience. The full source code is on GitHub.

Footnotes

  1. Originally published on my Hashnode blog in October 2019, my first tutorial. Revised lightly for this site. The code now lives in text instead of screenshots, and the JWT secret moved out of the source and into an environment variable, which is the one correction I owe my younger self in print.