Passwordless login with Express

Hunter Becton

Hunter Becton

Introduction

According to OneLogin, 81 percent of breaches involve weak or stolen passwords. Plus, passwords are a favorite target for cyber criminals. Why? Because many users practice insecure login practices, like using passwords they’re comfortable with or passwords they’ve used on other websites.

Passwordless login is more secure because no password is stored in the database. Instead, two tokens will be created by the server: one that is sent to the user via email and one that’s hashed, set to expire in 10 minutes, and stored in the database on the user.

The server will use the token from the email and check that a matching unexpired, hashed token is stored in the database on the user requesting authentication. If there’s a match, a JWT token and refresh token is created and sent to the user via cookies. Those tokens will then be used to gain access to protected routes.

In this guide you will learn about:

  • Node JS and NPM
  • Express servers
  • JWT and authentication
  • REST APIs
  • MongoDB and Mongoose
  • Git and GitHub
  • Heroku

Project repo

The code for this project is open source and available on GitHub. I use Gumroad for those that want to be generous and donate, but no purchase is required. 😊

Grab the code

Getting started

Before you get started creating your application there are a few items you need to ensure you have installed and configured on your local machine.

Code editor

In this guide I will be using VSCode, but feel free to use your favorite code editor. However, there are some extensions that this guide will use that might not be available on other code editors. Also, you will need to set up the code shortcut on Mac in order to open VSCode from the terminal.

Extensions

In this guide I will be using the following VSCode Extensions (you can install these directly from VSCode):

Terminal

In this guide I will be using iTerm, but feel free to use your favorite terminal or the one that comes installed on Windows or Mac.

Node and NPM

You will need to install Node and NPM on your local machine before starting this guide. Follow these steps to install Node on your operating system.

Git

Git is a version control system for tracking changes in source code during development. You’ll need to follow this guide to install Git on your operating machine.

Heroku

Heroku is where you’re going to host your final application. You’ll need to install the Heroku CLI to create a new Heroku project and push your Git repo for production.


MongoDB

Create a MongoDB Cluster

Sign up for a MongoDB account.

Go into projects and click on New Project and enter a project name. By default you will be added as the project owner. You can also invite others to your project, but for now keep it as default.

Click Build a Cluster and create a free cluster by selecting a shared cluster.

Select a cloud provider and a region for your cluster. I’ll select AWS and N. Virginia. Select M0 Sandbox to keep the base price at free unless you know you’ll need a larger tier.

Keep the default additional settings and rename the cluster to something that’s easily identifiable. I like to create development and production clusters, so using development or production in the cluster name helps keep things clear and organized.

It’ll take a few moments for the cluster to build. When it finishes the next thing you’ll do is head to Network Access and click Add IP Address. Select Allow Access from Anywhere. Confirm the change.

Setup MongoDB Compass

You’ll connect to the database from your local machine using MongoDB compass. The first thing you’ll need to do is download MongoDB compass. Open the software once it’s downloaded and click on New Connection to create a connection to your new database cluster.

Head back to MongoDB and click on Cluster from the cluster dashboard. Next, add a new database user and keep these credentials in mind because you’ll be using them in the next step.

On the next step you’ll select Connect using MongoDB compass. Download and install MongoDB Compass if you haven’t already. Since I already have it downloaded, I’ll select I have MongoDB Compass.

Select your version of Compass in order to get the appropriate connection string. The most recent string for the most current version should already be selected, so go ahead and copy the string below. If you’ve downloaded Compass in the past and have an older version you can either update it or select the version from the drop down menu to get the appropriate string value.

Head back to Compass and paste the string into the field. Now replace <password> with the password you created for your database user. Click Connect and you should now be connected to your database cluster!


Node

With your database created the next step is to create your Node project.

Open your terminal and navigate to where you want to create your project on your computer. In my case, I’ll change directories by running cd Documents/_skillthrive/_guides in the terminal.

Next, create a new directory in your terminal by running the command mkdir passwordless-login and then change directories into the new folder by running cd passwordless-login.

If you followed the steps in the introduction for setting up Node on your local machine, you can now run npm init -y. The -y flag uses the default values for the project information, so you can omit this if you’d like to add them yourself during creation. Of course, you can change these settings in your newly created package.json file later.

Open your project in VSCode by running the command code . in your terminal.

The first thing you’ll do is add all the dev dependencies that the project will require. All of these dependencies are related to code formatting and linting configuration that will help identify problematic patterns found in JavaScript code. We rely on these in development to help ensure we’re writing quality code before pushing it to production, which is why we don’t install these on the production server.

Open the terminal in VSCode with the hotkey CTRL + `. In the terminal you can install all the dev dependencies with the following command: npm i eslint eslint-config-airbnb eslint-config-prettier eslint-plugin-import eslint-plugin-jsx-a11y eslint-plugin-node eslint-plugin-prettier eslint-plugin-react prettier --save-dev


Express

The first step in creating an Express application is to install Express as a dependency by running npm i express.

From here there are a couple different approaches in initializing an Express app. What I like to do is to create an app.js file and a server.js file in the root directory.

app.js

Open the app.js file and require express at the top of the file. Then initialize Express by setting it to a new constant named app. Now export the new app constant so we can require it in the server.js file.

Your app.js file should look like this:

const express = require('express');
 
const app = express();
 
module.exports = app;

server.js

Open the server.js file and require the app at the top. We’ll be using environment variables in this project, so we need to install Dotenv as a new dependency to help us with this.

Dotenv is a module that loads environment variables from an .env file into process.env. This is useful because it allows you to separate secrets from your source code.

Now that you have a better understanding of why we’re using dotenv, go ahead and install it by running npm i dotenv. Once that’s installed go ahead and require it at the top of the server.js file.

Create a new file in the root of your project and name it config.env. At the top create a new variable for PORT and set it to 4010. Also create another variable for NODE_ENV and set it to development.

Your config.env file should look like this:

NODE_ENV=development
PORT=4010

Note that the variable names are in all caps. This isn’t necessary, but it’s a common naming practice because it makes it easier to identify them in your code.

With your environment variables added, go ahead and write the following code in your server.js file:

const app = require('./app');
const dotenv = require('dotenv');
 
dotenv.config({ path: './config.env' });
 
const port = process.env.PORT || 4010;
 
const server = app.listen(port, () => {
 console.log(`App running on port ${port} ...`);
});

After you require your dependencies, you need to tell your application where you’re storing your environment variables. To do that, you wrote dotenv.config( { path: './config.env/' } ).

Then you created a port constant that will either use the environment variable for PORT or a default value of 4010. That looks like the following: const port=process.env.PORT || 4010;. The console.log() is here to help you know when the server is successfully running.

Nodemon

You almost have everything setup to get your Express application running! All that’s left to do is to set up the NPM scripts in the package.json file and install a dependency called Nodemon.

Nodemon is useful in development because it will listen to changes in your files and restart the server whenever changes are made. This will save you a lot of headache from having to restart the servers manually each time a file is changed.

To install Nodemon you can run npm i nodemon.

NPM scripts

With Nodemon installed you can now write the NPM scripts in your package.json file that will be used to run your application in development and production. Add the following to your package.json file:

"scripts": {
   "start": "node server.js",
   "dev": "nodemon server.js",
   "prod": "NODE_ENV=production nodemon server.js",
 },

The first script you wrote is what will run when our application is in production on Heroku. That script is "start": "node server.js".

Below that you wrote the script to run your application in development: "dev": "nodemon server.js".

The last script you wrote is for production, but will only run on our local machine. This will help you test the application as if it’s running in production before the final deployment to Heroku.

In this script we’ll need to override the NODE_ENV so it’s set to production instead of development. It’s almost exactly the same as the development script with the exception of the environment variable change.

With the scripts done you can now run npm start dev in the terminal to start the development server. You should see App running on port 4010 … printing to the console.


Mongoose

Mongoose is an object modeling tool that will make working with your MongoDB database easy. First, stop your server with CTRL + C and install Mongoose by running npm i mongoose.

Open the server.js file once Mongoose is installed and write the following:

const app = require('./app');
const dotenv = require('dotenv');
const mongoose = require('mongoose');
 
dotenv.config({ path: './config.env' });
 
const DB =
 process.env.NODE_ENV == 'production'
   ? process.env.DATABASE_PROD
   : process.env.DATABASE_DEV;
 
mongoose
 .connect(DB, {
   useNewUrlParser: true,
   useCreateIndex: true,
   useFindAndModify: false,
   useUnifiedTopology: true,
 })
 .then(() => {
   console.log('DB Connection Successful');
 });
 
const port = process.env.PORT || 4010;
 
const server = app.listen(port, () => {
 console.log(`App running on port ${port} ...`);
});

In the sever.js file you created a connection to your MongoDB database using mongoose.connect(). To do that you also created a constant that holds the value for the connection string. This string will be different for development and production, so an environment variable is used here.

To get your connection string for your MongoDB cluster you will need to head back to MongoDB and click on the Connect button from when you connected Compass. However, this time select Connect your application.

Copy the string from the field and head back to your config.env file and create a new variable called DATABASE_DEV and set it equal to the copied value. Now replace password with the <password> you used to connect Compass.

In the constant that holds the database connection string you used a ternary operator that checks the Node environment. If the environment is production it will use the production database string and if it’s development it will use the development string.

In the next step you called mongoose.connect() to create the connection between Mongoose and your MongoDB database.

You’ll notice that the first argument is the database string followed by a couple of options. The options are passed in to handle deprecation warnings. You can read more about how to handle deprecation warnings in the Mongoose docs.

mongoose.connect() will return a promise, so you can test that the connection is successful by printing DB Connection Successful to the console.

Run npm start dev and if everything is correct you should see App running on port 4010 … and DB Connection Successful printing to the console. Now you have an Express server running that’s connected to your MongoDB database!


User model

Models are responsible for creating and reading documents from the MongoDB database. A model essentially sets up a blueprint for the information a document will hold. For example, the user model will hold the name, email, auth tokens, and refresh tokens of each user.

You should create a folder to keep your models organized. In your models folder create a new file called userModel.js.

Before writing any code you need to install a package by running npm i validator. You’re also going to be using a package called crypto, but it already comes installed with Node.

The validator package can be used to validate and sanitize string values in Node. You’re going to use it to validate a user’s email address. The crypto will be used to generate tokens and hashes (more on that below).

Now, go ahead and write the following in your userModel.js file:

const crypto = require('crypto');
const mongoose = require('mongoose');
const validator = require('validator');
 
const refreshToken = new mongoose.Schema({
 token: {
   type: String,
   trim: true,
 },
 expiration: {
   type: Date,
 },
 issued: {
   type: Date,
   default: Date.now(),
 },
 select: false,
});
 
const userSchema = new mongoose.Schema(
 {
   name: {
     type: String,
     trim: true,
   },
   email: {
     type: String,
     unique: true,
     required: [true, 'Email cannot be empty'],
     trim: true,
     lowercase: true,
     validate: [validator.isEmail],
   },
   authLoginToken: {
     type: String,
     select: false,
   },
   authLoginExpires: {
     type: Date,
     select: false,
   },
   refreshTokens: [refreshToken],
   active: {
     type: Boolean,
     default: true,
     select: false,
   },
   role: {
     type: String,
     enum: ['user', 'admin'],
     default: 'user',
   },
 },
 { timestamps: true }
);
 
userSchema.methods.createAuthToken = function () {
 const authToken = crypto.randomBytes(32).toString('hex');
 
 this.authLoginToken = crypto
   .createHash('sha256')
   .update(authToken)
   .digest('hex');
 
 this.authLoginExpires = Date.now() + 10 * 60 * 1000; // 10 minutes
 
 return authToken;
};
 
const User = mongoose.model('User', userSchema);
 
module.exports = User;

refreshToken schema

Below the required packages you created a new Mongoose schema for refreshTokens by declaring mongoose.Schema() and placing the schema object inside.

Inside the object you have 3 key values for token, expiration, and issued. Each one is an object that holds more information about the key. A type value is the only thing required. You can see a full list of schema types here.

The trim value will remove any unnecessary spaces at the beginning or end of a string.

The default value will set a default for a value when the refresh token is created. In this case, the default value for issued will be set to Date.now(), which will be the current date and time.

At the bottom of the refresh token schema is another option called select, which is set to false. This will ensure that refresh tokens aren’t returned when fetching data from MongoDB.

User schema

Below the refresh token schema you created the user schema. This schema will hold values for name, email, authLoginToken, authLoginExpires, refreshTokens, active, and role. I don’t think I need to explain what information name and email will be holding, but authLoginToken and authLoginExpires are a little different.

authLoginToken will be holding the token that the user will use when requesting to sign in. The request will see if a user exists with that token and if that user is the one making the request. If both are true your Express app will allow them to sign in and send back an authentication and refresh token in cookies.

authLoginExpires will be a date 10 minutes in the future, which will give your application some added security by ensuring the token cannot be used later.

name, authLoginToken, and authLoginExpires all use options that I discussed above in creating the refresh token, but email has a couple new ones I need to discuss.

First, you’ll see an option of unique that’s set to true. As you’ve might have guessed, this will ensure your database has no duplicate users with the same email.

Next, you’ll see an option of required that is set to an array with the values of true and a string of ‘Email cannot be empty.’ The first value ensures that the email is passed in when creating a new user and the second value is the error message that will be sent back to the request if there is no email provided.

lowercase is an option that will format the string to be lowercase–pretty straightforward.

The last option, validate, makes use of the validator package you required above. Similar to the required option, the validate option takes an array. In this array you called a method provided by the validator package named isEmail, which either returns true or false depending on if the provided email is valid.

The user schema will also hold all the current refresh tokens for the user. You’ll be storing this in an array called refreshTokens. You’ll notice that you passed in the refreshToken schema into this array as the value. This tells Mongoose that each item in this array should follow the information needed when creating a new refresh token.

The active value will track if a user is active or not, which defaults to true. Later in the course you’ll use this to allow a logged in user to delete themselves. In order to do this your application will set the active value to false instead of completely removing the user. This is considered a soft delete and ensures that only admins can fully remove all information about the user in the database.

The role value holds the value of user or admin, and defaults to user by default. You’ll also notice something new here on the role–key called enum. This is a validator that tells Mongoose to treat the string as an enumerator–only specific string values are accepted. Later in the guide you will create a utility to prevent users from changing their role. You will also create admin only routes to read, update, and delete users by their user ID.

Outside where you defined the user model object, but still inside where you defined the schema, is one last option where you defined timestamps. By setting this to true you’re telling Mongoose to automatically handle timestamps for createdAt and updatedAt. You’ll see these automatically generated for you later on when your first user is created.

createAuthToken method

In the next steps you will create methods on the user model that you can call elsewhere in your code. The first method will handle creating the auth token and will look like the following:

In the createAuthToken method you’re first creating an auth token using the crypto package. The first method you call from crypto is randomBytes(), which generates cryptographically strong pseudo-random data. The size argument is a number indicating the number of bytes to generate. Next, you call toString(‘hex’) to convert the binary data you get from calling randomBytes() to a string.

Now that you have a token generated, the next thing you want to do is hash the token that’s going to be stored in the database. A cryptographic hash is a kind of ‘signature’ for a text or a data file. The algorithm you’re using to create the hash is the SHA-256 algorithm. This algorithm generates an almost-unique 256-bit (32-byte) signature for a text.

For security purposes, a hash is not encryption – it cannot be decrypted back to the original text. Therefore, if someone somehow got their hands on the value in the database they would not be able to use it to login as the user. Instead, the value in the database can only be compared on your server to check if the hashed values match.

By calling this.authLoginToken you’re telling Mongoose to set the value for authLoginToken to the current user. Remember, authLoginToken is a value that’s going to be stored on the user in the MongoDB database. On this value you can hash the auth token from above by calling crypto.createHash('sha256').

Then you’ll chain on another method called update() and pass in the original auth token. This will update the value originally generated at the top to hashed value. Now, just like before, you’ll convert this new value to a hex string by chaining the method digest('hex').

To finish the user method you need to create the authLoginExpires value. Just like with authLoginToken, you can set the current user’s authLoginExpires value by calling this.authLoginExpires. You’ll set this value to a timestamp 10 minutes in the future. To do that you call Date.now(), which will create a timestamp for the current time, and then add 10 minutes (represented in milliseconds) onto the value. To get the milliseconds in 10 minutes you multiply 10 by 60 (how many seconds in a minute) and then multiply that value by 1000 (how many milliseconds in a minute).

The last thing you need to do to finish the user model is to tell Mongoose the name of the document, pass in the schema, and then export the document so you can access it in other files. You define the model with mongoose.model(), passing in the name of the model and the schema. To export the user model with Node you call module.exports and set it equal to what you want to export, in this case the User.


Utilities

Before you write the code to handle the passwordless login, you’re going to write some utility functions that will help you work with sending email and handling errors. You’ll be storing these utilities in a folder called utils, so go ahead and create that in the root of your project. In the folder create three files: appError.js, catchAsync.js, and email.js.

Email

SendGrid

In this guide you’ll use SendGrid as your email service provider. Not only do they have a great API, but their pricing is fair. They also make it incredibly easy to send custom emails using their Dynamic Templates.

If you haven't already, head to SendGrid and sign up for an account. Before you start sending with SendGrid you’ll need to verify your sender identity (you own the domain and email you’re sending emails from). For testing it’s fine to use Single Sender Verification, but I suggest you setup Domain Authentication for your production application.

You’ll be using a free email template to send the email to the user with their auth link for logging in. Head to the link to the GitHub repo and download the zipped code from the Code dropdown button.

Unzip the file once it’s downloaded and open the email.html file in VSCode. Feel free to change the styles, but for now scroll down to the bottom where the HTML is located.

You can customize the email to fit the message you want, but one thing you need to ensure you change is the CTA link. Instead of a hardcoded href value, you’ll need to use a SendGrid Handlebar. A Handlebar is a templating syntax that SendGrid uses to identify what parts of the email are dynamic. In this case, change the href in the CTA to href={{ url }}. Now you’ll be able to send a variable called url over to the SendGrid API when sending the auth email and it will automatically place the dynamic URL in the template.

One more thing you need to ensure you change is the information in the footer. Be sure to include your business address and change the unsubscribe link to href="[unsubscribe]". By doing this SendGrid will include the appropriate link for a user to unsubscribe from your emails. Don’t skip this–it’s required!

Be sure to check out the complete email.html file in the course files If you want to see the complete code.

The last thing you need to do before uploading this template to SendGrid is to inline the CSS. This will ensure that your styles are fully supported. You’ll use a free service called Responsive Email CSS Inliner to do this task.

Once there, just copy the code from the email.js file, paste it into the CSS inliner, and your new inline CSS email template will be generated below.

Create a new file called (duplicate the email.html file and rename it) on your computer, open the file in VSCode, and paste the code inside. Last, save the file. Now your template is ready for SendGrid!

Head back to SendGrid and navigate to Email API > Dynamic Templates. Click on the Create a Dynamic Template button at the top and give your template a name. You’ll see the new template added below. Click on it to expand the section and click on the Add Version button.

Select Blank Template and then Code Editor. Copy the html from the email-auth-inline.html file and paste it in the HTML section. You should see a preview of the email to verify everything looks correct.

Before saving click on the settings menu on the left to expand the window. Give the template version a name and a subject line. A good subject line would be: ‘Your login link–valid for 10 minutes.’ Click Save once you have those two fields completed. Last, click the arrow to head back to the SendGrid dashboard.

Email class

With the email template complete you can now create the Email class that will handle all the email functionality for your application. You can think of a class as a type of function, but instead of using the keyword function to initiate it, you use the keyword class, and the properties are assigned inside a constructor() method.

Before you write the Email class, you need to install the Sendgrid Node mail service by running npm i @sendgrid/mail. Open your email.js file and require the package at the top.

In order to use the SendGrid package you need to have an API key. You can create a new key from the dashboard by navigating to Settings > API keys. Click on Create API Key, give the API key a name, click on Full Access, and then click on Create & View. Now copy the API key shown and head back into your config.env file. Create a new variable named SENDGRID_API_KEY and set it equal to the API key you copied.

You will run into an error with SendGrid when using environment variables because the config you added on server.js will not allow the SendGrid package to know about your environment variables. Therefore you’ll need to require and config dotenv before initializing SendGrid.

Call the constant you created when requiring the SendGrid package and use a method named setApiKey and pass in the api key environment variable. This will initialize SendGrid and in your application and allow you to send emails.

Now write the following code in your email.js file:

const sgMail = require('@sendgrid/mail');
require('dotenv').config({ path: './config.env' });
 
sgMail.setApiKey(process.env.SENDGRID_API_KEY);
 
module.exports = class Email {
 constructor(user, url) {
   this.to = user.email;
   this.url = url;
   this.fromEmail = 'you@example.com';
   this.fromName = 'Your Name';
 }
 
 async sendMagicLink() {
   const mailOptions = {
     to: this.to,
     from: {
       email: this.fromEmail,
       name: this.fromName,
     },
     templateId: 'your-template-id',
     dynamic_template_data: {
       url: this.url,
     },
   };
 
   try {
     await sgMail.send(mailOptions);
   } catch (error) {
     console.log(error);
   }
 }
};

You’ll be using the Email class in other files in your project, so you exported the class in the same line you initiated it using module.exports. Then you used the class keyword to initialize the class.

Class constructor

The constructor method is where you initialize properties. The arguments ofuser and url will be passed in when using the Email class in other files. The properties inside the constructor use the this keyword, which refers to the current instance of the class object. These four properties– to, url, fromEmail, and fromName–will be used below in the sendMagicLink method.

sendMagicLink

sendMagicLink is a method you created that can be called when using an Email class. This method is asynchronous–will return a promise from calling the SendGrid API–so you used the async keyword in front of the method declaration.

Inside the method you created a new object called mailOptions that will hold important information that will be passed to SendGrid when sending an email. I created these keys based on a SendGrid transactional email template example.

Be sure to replace templateId with id from the template you created earlier. You can find the id from the SendGrid dashboard by navigating to Email API > Dynamic Templates and clicking on the template below.

Remember that url variable you used inside the HTML email template? The dynamic_template_data key is an object that is used to define such variables. In this case you’re setting url equal to the url argument passed into the Email class.

The last step to finish the Email class was to create a try / catch block where you send the email with SendGrid and catch any errors. Inside the try block you called a method on SendGrid named send() and passed in mailOptions. You’re using an async function here, so you called await before calling the send() method because you’re awaiting the response from SendGrid. In the catch block you’re catching any errors and printing them to the console.

With that done you’re now ready to send your users a login email with a token for logging into your application!

AppError

The AppError class is going to be used to return errors in your application. Open your appError.js file and write the following code:

module.exports = class AppError extends Error {
 constructor(message, statusCode) {
   super(message);
 
   this.statusCode = statusCode;
   this.status = `${statusCode}`.startsWith('4') ? 'fail' : 'error';
   this.isOperational = true;
 
   Error.captureStackTrace(this, this.constructor);
 }
};

The AppError class is a class based on a child of another class–in this case the built-in Error class (read more about Error). You use a keyword called extends to create the child class.

The constructor will take two arguments: message and statusCode. Inside the constructor you’ll notice a new keyword called super. The super keyword is used to access and call functions within the parent class. In this example you’re passing a message argument into the AppError class and telling the class to use the message passed in with the built-in message function from the parent Error class.

The statusCode is also an argument passed into the class. It will be used within the status property to either set the status to fail if your application gets a HTTP status code starting with 4 or error for other status codes. You used a ternary operator to test for this condition.

The last line in the AppError class calls a method on the Error class called captureStackTrace(). In short, this is used to exclude the constructor from the stack trace, which is the point in the code at which the Error was instantiated.

catchAsync

You’ll be writing asynchronous request handlers in your Express server and you’ll need to use try/catch to catch any errors. However, it’s annoying to have a try/catch statement in each request handler. The catchAsync code will help you work with try/catch statements by wrapping the request handler and converting the try/catch to a promise. Open your catchAsync.js file and write the following code:

module.exports = (fn) => {
 return (req, res, next) => {
   fn(req, res, next).catch(next);
 };
};

Later in the course you’ll see examples of how you’ll wrap your asynchronous request handlers to take advantage of the catchAsync utility.

Read more.


Auth controller

In the next few sections you’re going to be writing the code for your project’s controllers–the code that will handle requests on your server. Go ahead and create a folder called controllers to hold all your controller code. Then create a new file called authController.js.

Require everything you need at the top and initialize SendGrid:

const crypto = require('crypto');
const { promisify } = require('util');
const jwt = require('jsonwebtoken');
const sgMail = require('@sendgrid/mail');
 
const User = require('../models/userModel');
const catchAsync = require('../utils/catchAsync');
const AppError = require('../utils/appError');
const Email = require('../utils/email');
require('dotenv').config({ path: './config.env' });
 
sgMail.setApiKey(process.env.SENDGRID_API_KEY);

You might have noticed that you required promisify from the util package. This package comes with a new Node project, so there’s no need to install it separately. However, you will need to install the jsonwebtoken package by running the following in your terminal: npm i jsonwebtoken.

Below your requirements you also initialized your SendGrid api by calling the setApiKey method and passing in your SendGrid API Key from your config.env file.

signToken

The signToken function will be responsible for creating and returning a JWT token based on a user ID that’s passed in. This function will use the jwt function from the jsonwebtoken package that you required above.

In order to create the JWT token you will need to provide a secret and expiration. These values will be stored as environment variables, so go ahead and create two new variables in your config.env file and name them JWT_SECRET and JWT_EXPIRES_IN.

You want your JWT secret to be a random string of at least 16 alphanumeric characters. Set your JWT_EXPIRES_IN value to 30m.

Now write the following code:

const signToken = (id) => {
 return jwt.sign({ id }, process.env.JWT_SECRET, {
   expiresIn: process.env.JWT_EXPIRES_IN,
 });
};

createSendToken

The createSendToken function will create an access and refresh token for the user, store a hashed version of the refresh token in the database with an expiration date, and then send the access token and refresh token to the user via cookies.

Write the following code in your file:

const createSendToken = catchAsync(async (user, statusCode, res, req) => {
 const token = signToken(user._id);
 
 // Generate the random refresh token
 const refreshToken = crypto.randomBytes(32).toString('hex');
 
 const hashedRefreshToken = crypto
   .createHash('sha256')
   .update(refreshToken)
   .digest('hex');
 
 const refreshExpiration = new Date().setDate(new Date().getDate() + 7); // 7 days
 
 res.cookie('refreshToken', refreshToken, {
   httpOnly: true,
   sameSite: process.env.NODE_ENV == 'production' ? 'none' : 'Lax',
   secure: process.env.NODE_ENV == 'production' ? true : false,
   maxAge: 604800000, // 7 days
 });
 
 res.cookie('accessToken', token, {
   httpOnly: true,
   sameSite: process.env.NODE_ENV == 'production' ? 'none' : 'Lax',
   secure: process.env.NODE_ENV == 'production' ? true : false,
   maxAge: 1800000, // 30 minutes
 });
 
 await User.findByIdAndUpdate(user._id, {
   $push: {
     refreshTokens: {
       token: hashedRefreshToken,
       expiration: refreshExpiration,
     },
   },
 });
 
 res.status(statusCode).json({
   status: 'success',
   token,
   data: {
     user,
   },
 });
});

The createSendToken is the first function that uses the catchAsync wrapper that you created earlier in the project. Remember that this simple utility will help you write less code by capturing any errors automatically.

There’s no need to export this function because you will not be using it to define your routes later in the project. Instead, this function is only going to be used within the scope of this file–more specifically, it will be used later in the verifyAuthLink function.

This function will take 4 arguments: user, statusCode, res, and req. These arguments will be passed in via the verifyAuthLink function you’ll write soon.

The first thing you did in the function is create a new token by calling the signToken function you just created. This will be used to authenticate the user and will only be valid for 30 minutes.

Because the auth token is only valid for 30 minutes you need a way to let the user get a new token when it expires. This is where refresh tokens come into play. The next part of the code creates a refresh token using the crypto package.

You already covered what the crypto package is being used when you created the createAuthToken method on the user object. However, just in case you missed it, here’s the explanation from above:

The first method you call from crypto is randomBytes(), which generates cryptographically strong pseudo-random data. The size argument is a number indicating the number of bytes to generate. Next, you call toString(‘hex’) to convert the binary data you get from calling randomBytes() to a string.

Now that you have a token generated, the next thing you want to do is hash the token that’s going to be stored in the database. A cryptographic hash is a kind of ‘signature’ for a text or a data file. The algorithm you’re using to create the hash is the SHA-256 algorithm. This algorithm generates an almost-unique 256-bit (32-byte) signature for a text.

For security purposes, a hash is not encryption – it cannot be decrypted back to the original text. Therefore, if someone somehow got their hands on the value in the database they would not be able to use it to login as the user. Instead, the value in the database can only be compared on your server to check if the hashed values match.

Now that you have your token and refresh token created, the next thing you did was create a constant for a date value 7 days away. This value will be used to set an expiration on your user’s refresh tokens and stored in the database.

The next couple lines of code will create and send a cookie back to the user. You created the token by calling the cookie() method on res. The first argument is the name of the cookie, the second argument is the value, and the third argument is an object of options for the cookie.

Let’s discuss the options passed on the cookies individually:

httpOnly

httpOnly is a tag added to a browser cookie that prevents client-side scripts from accessing data. It provides a gate that prevents the specialized cookie from being accessed by anything other than the server.

Read more.

sameSite

sameSite is a tag that allows you to declare if your cookie should be restricted to a first-party or same-site context. sameSite=none clearly communicates that you intentionally want the cookie sent in a third-party context, but this can only be used if the tag secure is true.

During development you don’t want to have sameSite set to none because secure will be false (as seen in the ternary operator). This is why you need to set sameSite=lax for when your application is in development.

Read more.

secure

The secure tag will forbid the use of a cookie without HTTPs if set to true. Because your application will not use HTTPs until it's in production you need to ensure that this is set to false while developing. You achieved this by writing a simple ternary operator that checks the Node environment.

Read more.

maxAge

The maxAge tag set the expiration for a cookie. The value needs to be in milliseconds. Once the cookie expires your user’s browser will remove the cookie.

protect

The protect function is going to protect routes by checking if the user passed a valid auth token when making a request. If the user isn’t authenticated then the function will return an error letting the user know they need to log in.

Write the following code in your file:

exports.protect = catchAsync(async (req, res, next) => {
 // Get token and check if it exists
 let token;
 
 if (req.cookies && req.cookies.accessToken) {
   token = req.cookies.accessToken;
 }
 
 if (!token) {
   return next(
     new AppError('You are not logged in. Please log in to get access', 401)
   );
 }
 
 try {
   // Verify token
   const decoded = await promisify(jwt.verify)(token, process.env.JWT_SECRET);
 
   // Check if user exists with refresh token
   const currentUser = await User.findById(decoded.id);
   if (!currentUser) {
     return next(
       new AppError('The user belonging to this token no longer exist.', 401)
     );
   }
 
   // Grant access to protected route
   req.user = currentUser;
 } catch (err) {
   console.log(err);
   return next(
     new AppError('You are not logged in! Please log in to get access.', 401)
   );
 }
 
 next();
});

This function takes in 3 arguments that are available to you through Express: req, res, and next. The next function is used to hand off control to the next callback. This will be more clear once you set up your routes later in the project.

At the beginning of the function you defined token, setting it as let because you’ll be overriding the value later. Below that you’re checking if the req has cookies forward along. If it does have cookies then you’re checking if the accessToken cookie exists. If so, you’re setting the token value you defined at the beginning to the accessToken cookie.

If a token doesn’t exist your app is returning a new error using the Error class from earlier.

Now that the function has handled if a token exists or doesn’t exist, you need to check if the token is still valid. You’ll start by creating a new try / catch block. In the try block the first thing you did was to decode the token that was passed in using the jsonwebtoken library.

It’s important that you pass your JWT secret when decoding because it will work as a key to decode the token. If the token has expired you will get an error, which will be handled in the catch block.

On the decoded token will be the user ID that was used when originally signing the token. Your app can now use that user ID to check if a user in the database exists. You're able to do this on the User model using a Mongoose method called findById().

If the user exists your app will pass along a new value on the req called user, which will hold all the information about the user from the database. If the user doesn’t exist your app will throw an error.

In the catch block you’ll return an error and message that the user needs to sign in to get access. Last, you’ll want to call next() so your application can move to the next callback function. Again, the use of next() will make more sense when you start to chain these callback functions when defining your routes.

restrictTo

The next function you’ll write will check if the user has the authorization to make a request. For example, requests like deleting a user will only be authorized for users with the role of admin.

Write the following code in your file:

exports.restrictTo = (...roles) => {
 return (req, res, next) => {
   // Roles in an array
   if (!roles.includes(req.user.role)) {
     return next(
       new AppError('You do not have permission to perform this action', 403)
     );
   }
 
   next();
 };
};

The restrictTo function will be passed string values of the roles that you want to allow authorization. This function will proceed the protect function, which will give it access to the user object on req.user. The function will then check if the user role on the user object matches the one passed in as an argument. Last, the function calls next() to move to the next function.

The sendAuthLink function will be used to send the user the url to validate login. It will also create a new user in the database if they don’t exist.

Write the following code in your file:

exports.sendAuthLink = catchAsync(async (req, res, next) => {
 // Get user based on POSTed email
 let user = await User.findOne({ email: req.body.email });
 
 if (!user) {
   user = await User.create({
     email: req.body.email,
   });
 }
 
 // Generate the random auth token
 const authToken = user.createAuthToken();
 await user.save({ validateBeforeSave: false });
 
 const authLink = `${process.env.HOST}/verify#loginToken=${authToken}`;
 
 try {
   await new Email(user, authLink).sendMagicLink();
 
   res.status(200).json({
     status: 'success',
     message: 'Check your email to complete login.',
   });
 } catch (err) {
   user.authLoginToken = undefined;
   user.authLoginExpires = undefined;
   await user.save({ validateBeforeSave: false });
 
   return next(
     new AppError(
       'There was an error sending the email. Try again later!',
       500
     )
   );
 }
});

The first line of code will check if a user exists based on the email address that was passed on the req.body. To achieve this you call the findOne() Mongoose method on the User model, passing it the values you want to search for in the database.

The next line will create a new user in your database if they do not exist. Again, Mongoose provides you with a handle method called Create() where you can pass in all the information you need to create a user. In this case, it’s just the email.

With the user now in your database you can create a new token by calling the createAuthToken() method that you created when defining the User model. Don’t confuse this token with the auth and refresh token. This token will expire in 10 minutes and is going to be created, sent to the user via email to login, and then a hashed version will be stored in the database. After the token is created you want to save the token and token expiration to the user.

After the token is saved to the user your application will create the link that will be sent to the user to login via email. You’ll notice that you’re using a new environment variable called HOST, which is going to be where your frontend application will be hosted. For example, React apps created with Create React App usually have a host of http://localhost:3000. You’ll also notice that the token is placed at the end of the url with a leading pound sign (#). This is going to allow you to get access to the token on req.params.token in your verifyAuthLink function. More on that in the next section.

Now you need to create a try / catch block. Inside the try block you start by sending a new email using the Email class and calling the method sendMagicLink().

Once the email is sent you can send a 200 status back to the user to check their email to finish logging in. The catch block will catch any errors, set the user authLoginToken and authLoginExpires to undefined in the database, and return a new app Error with a status of 500 and a custom message.

The verifyAuthLink function will verify the token sent to a user’s email and send back a JWT auth token and refresh token if successful.

Write the following code in your file:

exports.verifyAuthLink = catchAsync(async (req, res, next) => {
 // Get user based on token
 const hashedToken = crypto
   .createHash('sha256')
   .update(req.params.token)
   .digest('hex');
 
 const user = await User.findOne({
   authLoginToken: hashedToken,
   authLoginExpires: { $gt: Date.now() },
 });
 
 if (!user) {
   return next(new AppError('Token is invalid or expired', 400));
 }
 
 // If the user exists and token isn't expired, remove token and send JWT token
 user.authLoginToken = undefined;
 user.authLoginExpires = undefined;
 await user.save();
 
 // Log the user in and send JWT
 createSendToken(user, 200, res, req);
});

The first thing this function needs to do is hash the token that was sent over in the req.params.token because the hashed token is what your app will use to find a matching user in the database. You create the hashed token using the crypto package, similar to what you did when creating the refresh token earlier.

Once the token has been hashed your application will use the findOne() Mongoose method on the User model to search for a user with a matching token. You also are searching for a user that only has a token that has an expiration date greater than the current date. You’re achieving this by using the $gt MongoDB comparison operator, which only selects those documents where the value of the field is greater than the specified value.

If no user can be found your application will return an error. Otherwise, your application will set the authLoginToken and authLoginExpires values on that user to undefined, save the updated user in the database, and then pass the user object to the createSendToken() function. As a reminder, this function will create an auth and refresh token, handle saving the refresh token in the database, and then return the auth token and user information in the response.

logout

The logout function will remove all refresh tokens for a user in the database and in the user’s browser.

Write the following code in your file:

exports.logout = catchAsync(async (req, res, next) => {
  // Remove refreshTokens from database
  req.user.refreshTokens = [];

  // Set cookies to expired
  res.clearCookie('refreshToken', {
    httpOnly: true,
    sameSite: process.env.NODE_ENV == 'production' ? 'none' : 'Lax',
    secure: process.env.NODE_ENV == 'production' ? true : false,
    maxAge: 0,
  });

  res.clearCookie('accessToken', {
    httpOnly: true,
    sameSite: process.env.NODE_ENV == 'production' ? 'none' : 'Lax',
    secure: process.env.NODE_ENV == 'production' ? true : false,
    maxAge: 0,
  });

  await req.user.save();

  res.status(200).json({
    stutus: 'success',
    data: {},
  });
});

The first line sets the refreshTokens on the user object to an empty array, which removes any all the refresh tokens previously stored. However, this won’t remove the tokens from the database until you call req.user.save() later in the function.

The next couple of lines of code use the clearCookie() method from Express to remove specific cookies from the user’s browser. This method is similar to the cookie() method, except it only needs the name of the cookie instead of the value.

After you wrote the code to remove the cookies you wrote the code to save the user to the database, which is going to set refreshTokens to an empty array. Last, your app will send a success response with an empty data object.

isLoggedIn

The last function in your auth controller will check if a user is logged in. It will also try and create a new auth token for the user if the request only has a refresh token cookie. If the refresh cookie is expired or no cookies exist the function will return an error asking the user to log in.

This function is useful when you start to write your frontend code and need to quickly check if a user is logged in and can access a route or if you need to render different elements to the DOM depending on the user’s login status.

Write the following code in your file:

exports.isLoggedIn = catchAsync(async (req, res, next) => {
 let token;
 let refresh;
 
 if (req.cookies && req.cookies.accessToken) {
   token = req.cookies.accessToken;
 }
 
 if (req.cookies && req.cookies.refreshToken) {
   refresh = req.cookies.refreshToken;
 }
 
 if (!token && !refresh) {
   return next(
     new AppError('You are not logged in. Please log in to get access', 401)
   );
 }
 
 // Attempt to get new auth token with refresh
 if (!token && refresh) {
   try {
     // Get user based on hashed refresh token
     const hashedRefreshToken = crypto
       .createHash('sha256')
       .update(refresh)
       .digest('hex');
 
     // Check if user exists with refresh token
     const refreshUser = await User.findOne({
       'refreshTokens.expiration': { $gt: Date.now() },
       'refreshTokens.token': hashedRefreshToken,
     });
 
     if (!refreshUser) {
       return next(
         new AppError(
           'You are not logged in. Please log in to get access',
           401
         )
       );
     }
 
     // Create new token
     const refreshAuthToken = signToken(refreshUser._id);
 
     // Send new access token in cookie
     res.cookie('accessToken', refreshAuthToken, {
       httpOnly: true,
       sameSite: process.env.NODE_ENV == 'production' ? 'none' : 'Lax',
       secure: process.env.NODE_ENV == 'production' ? true : false,
       maxAge: 1800000, // 30 minutes
     });
 
     // There is a logged in user
     res.status(200).json({ status: 'success', data: refreshUser });
   } catch (err) {
     res.status(401).json({ status: 'error', data: null });
   }
 }
 
 if (token) {
   try {
     // Verify token
     const decoded = await promisify(jwt.verify)(
       token,
       process.env.JWT_SECRET
     );
 
     // Check if user still exists
     const currentUser = await User.findById(decoded.id);
 
     if (!currentUser) {
       return res.status(401).json({ status: 'error', data: null });
     }
 
     // There is a logged in user
     res.status(200).json({ status: 'success', data: currentUser });
   } catch (err) {
     res.status(401).json({ status: 'error', data: null });
   }
 }
});

The first couple lines of code defines the auth token and refresh token so you can change their values later.

The first if statement will handle a request where the auth token and refresh token don’t exist. This will return a 401 error with a message for the user to log in.

The next if statement will handle a request where the auth token doesn’t exist but a refresh token does. Inside the statement you need a try / catch block that will check if a user with a refresh token exists in the database and then create a new auth token if true.

First, your application needs to create a hashed token because the hashed token is what’s stored in the database. Once the hashed token is created you can use the findOne() method on the User model to find a user with the token that hasn’t expired.

You might have noticed a different syntax when accessing the expiration and token keys on the refreshToken in the database, which is 'refreshTokens.expiration' and 'refreshTokens.token'. This syntax allows you to read key values inside an object that’s in your MongoDB database. This syntax is similar to how you’d access key values in JavaScript, except you’re writing it as a string.

If no user is found your app will return an error. Otherwise, your application can proceed creating a new auth token. Then your application can send over the accessToken via a cookie in the response. Last, a success response will be sent with the user’s data.

The last if statement checks if an auth token exists on the request cookies. In this case your application will decode the JWT token and check if it’s still valid. If it is still valid your application will be able to read the user ID. Then it can use the user ID to check if a user with a matching user ID exists.

If a user doesn’t exist the response will be an error, else, the response will be a success with the user’s data.


Auth routes

With your auth controller done it’s now time to create the routes so your application can start using the new functions. Just like you did with controllers and models, your routes will live in a new folder called routes.

Inside this new folder go ahead and create a new file called userRoutes.js. This file is going to hold the routes for authentication as well as the routes for the user, which will be covered in the user controller section.

Write the following code in your new file:

const express = require('express');
const authController = require('./../controllers/authController');
 
const router = express.Router();
 
router.post('/authLogin', authController.sendAuthLink);
 
router.post('/authVerify/:token', authController.verifyAuthLink);
 
router.get('/isLoggedIn', authController.isLoggedIn);
 
// Protected routes
router.use(authController.protect);
 
router.post('/logout', authController.logout);
 
module.exports = router;

To start defining your routes you need to require Express and the authController. Next, you need to define a constant called router by calling the Router() method on express. Now you can build your routes on router.

The first route will be a post route, which you create by calling router.post(). The first argument passed in will be the location of the route and the following arguments will be the functions from the controllers you want to apply to the route. Order matters here because a function placed before another will run first.

The first couple of routes you defined are public, meaning that anyone making the request to the server doesn’t need to be authenticated. This makes sense for routes like logging in because the user doesn’t have or need a token to make the request. However, some routes need to be protected, which is where the protect function comes into use.

You could add the protect function to each request just like you did with the other functions from authController, but an easier way is to call the method use() and pass in the protect function. By doing this any route below will automatically use protect. The only route that needs to be protected is the ‘/logout’ route because only users that are signed in should be able to access this route.

With your auth routes defined you can now open your app.js file and tell your Express server to use the routes. Your app.js file should look like this:

const express = require('express');
 
const userRouter = require('./routes/userRoutes');
 
const app = express();
 
// Routes
app.use('/api/v1/users', userRouter);
 
module.exports = app;

You start by requiring the userRouter and calling use() on the app to tell your Express server to run all routes from the user router at ‘/api/v1/users’. Now your routes for authentication will look similar to http://localhost:4010/api/v1/users/authLogin.


Auth testing

Your auth controller and routes are now ready for testing! In this guide you will be using a VS Code extension called REST Client to test your API. There are several other tools out there for testing your API, including one of my favorites: Postman. However, what’s great about REST Client is that it’s easy to share the code with you and less of a learning curve compared to Postman.

Go ahead and install REST Client by heading to your extensions tab on the left hand side of VS Code and searching for REST Client. Then install it on VS Code.

Once installed create a new file in your root directory called test.rest. This is where you’ll write the endpoints to test your api. In the file write the following code:

@host = http://localhost:4010/api/v1
 
# @name login
POST {{host}}/users/authLogin HTTP/1.1
Content-Type: application/json
 
{
   "email": "test@example.com"
}
 
###
 
@verifyToken = TokenGoesHere
 
# @name verify
POST {{host}}/users/authVerify/{{verifyToken}} HTTP/1.1
 
###
 
# @name isLoggedIn
GET {{host}}/users/isLoggedIn HTTP/1.1
 
###
 
# @name logout
POST {{host}}/users/logout HTTP/1.1

At the top you can see how variables are defined in a .rest file using the following format: @variable = variable value. You then can use the variable in other parts of the file with the following format: {{ variable }}. This is useful for values that are repeated across your REST API, for example, your server host URL. If it ever needs to change you just need to change it in one spot, just like variables in JavaScript or CSS.

Below the host variable you will see syntax for defining a name for an endpoint: # @name login. Then you will see the actual endpoint, first with the HTTP method (POST), followed by the endpoint, and last HTTP/1.1.

Below the endpoint you can add headers to the request. In order to send JSON on the body you will need to add Content-Type: application/json. Then you can write the JSON for the login endpoint, passing in a value for the email you want to send the verification token.

A new endpoint is defined with ###. Every other endpoint below will follow a similar process as described above, except with new values.

Before you test your endpoints there are a couple things you still need to do for your server to parse the body and cookies. Those changes will require you to install a new package called cookie-parser, so go ahead and install it by running npm i cookie-parser in the terminal.

Now open your app.js file and add the following code above where you defined your routes:

// Body parser, reading data from body into req.body
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
app.use(cookieParser());

Once your endpoints are defined you will see a Send Request text above each one. You can click on this text and VS Code will open a new tab with the request response.

The login route will send an email with the verification token you need to verify the login request. Once you click on the link in the email a new tab will open. Copy everything past #loginToken= and paste it in the verifyToken value in your test.rest file. Then you can proceed with Send Request.


User controller

APIFeatures

The userController will be used to create, read, update, and delete users from the database. Before you write the userController you’ll create a new utility that will help make use of Mongoose query methods like filter(), sort(), limitFields(), and paginate().

Create a new file in your utils folder called apiFeatures.js. Write the following code in the file:

class APIFeatures {
 constructor(query, queryString) {
   this.query = query;
   this.queryString = queryString;
 }
 
 filter() {
   const queryObj = { ...this.queryString };
   const excludeFields = ['page', 'sort', 'limit', 'fields', 'mine'];
   excludeFields.forEach((el) => delete queryObj[el]);
 
   // 2. Advanced filtering
   let queryStr = JSON.stringify(queryObj);
   queryStr = queryStr.replace(/\b(gte|gt|lte|lt)\b/g, (match) => `$${match}`);
 
   this.query = this.query.find(JSON.parse(queryStr));
 
   return this;
 }
 
 sort() {
   if (this.queryString.sort) {
     const sortBy = this.queryString.sort.split(',').join(' ');
     this.query = this.query.sort(sortBy);
   } else {
     this.query = this.query.sort('-createdAt');
   }
 
   return this;
 }
 
 limitFields() {
   if (this.queryString.fields) {
     const fields = this.queryString.fields.split(',').join(' ');
     this.query = this.query.select(fields);
   } else {
     this.query = this.query.select('-__v');
   }
 
   return this;
 }
 
 paginate() {
   const page = this.queryString.page * 1 || 1; // Convert string to number
   const limit = this.queryString.limit * 1 || 100;
   const skip = (page - 1) * limit;
 
   this.query = this.query.skip(skip).limit(limit);
 
   return this;
 }
}
 
module.exports = APIFeatures;

The APIFeatures class takes two arguments, query and queryString, which are defined in the constructor.

filter()

The exclude fields constant will take values like sort, limit, and field and remove them from the queryString. Then the filtered query is formatted as a JSON string.

The query might also have operators passed in like gte and lte, but those values need to have a $ sign in front for proper syntax. Therefore you need to use a replace() method and RegEx to find these operators and add a $ in front of them. Once that filter string is properly formatted the function will pass it into find() method.

sort()

The sort() method will take the queryString values passed in on the sort values. The function will then take the sort string, remove the commas, and then join the values by a space. This will ensure that the sort value will be properly formatted for Mongoose.

The sort() method also has a default sorting method that will return the results in an descending order based on the createdAt value in the database. You can change if your results are sorted descendingly by adding a - in front of the value.

limitFields()

The limitFields() method removes values from being returned in the results. The setup of this method is essentially the same as sort, but the default value is -__v. This is a value that MongoDB automatically creates to track versions, so there’s no need to return the data.

It’s also important to point out that the - in front of the value doesn’t stand for descending, rather it means to remove that value from the results.

paginate()

The paginate() method takes 3 values from the queryStringpage, limit, and skip–and converts the string values to numbers. Those numbers are then passed into the skip and limit Mongoose methods to create paginated results.

filterObj

There’s one last utility you need to create called filterObj. This utility will filter out values on the req body that are allowed by users. This is important because you don’t want a user to make a request to this endpoint and change something like their role to admin.

Create a new file called filterObj.js in your utils folder and add the following code:

const filterObj = (obj, ...allowedFields) => {
 const newObj = {};
 Object.keys(obj).forEach((el) => {
   if (allowedFields.includes(el)) newObj[el] = obj[el];
 });
 return newObj;
};
 
module.exports = filterObj;

This function first takes the req.body and the allowed fields as arguments. Then the function will take the key values from the req.body and compare them to the ones that are allowed. If those keys match the ones that are allowed then the key and value will be added into a new object. Last, the function will return the new object.

Now that the APIFeatures and filterObj are complete you can create a new file called userController.js in your controllers folder. In that file you can start by requiring everything you need:

const User = require('../models/userModel');
const catchAsync = require('../utils/catchAsync');
const AppError = require('../utils/appError');
const APIFeatures = require('../utils/apiFeatures');
const filterObj = require('../utils/filterObj');

updateMe

The updateMe function will allow logged in users to update their email. Write the following code below:

exports.updateMe = catchAsync(async (req, res, next) => {
 // Filter field names that are allowed
 const filteredBody = filterObj(req.body, 'email');
 
 // Update user document
 const updatedUser = await User.findByIdAndUpdate(req.user._id, filteredBody, {
   new: true,
   runValidators: true,
 });
 
 if (!updatedUser) {
   return next(new AppError('No user found with that ID', 404));
 }
 
 res.status(201).json({
   status: 'success',
   data: {
     updatedUser,
   },
 });
});

The first thing this function needs to do is filter only the allowed fields that a user can change, which is email. To accomplish this you will use your filterObj() function that you created earlier, passing in the req.body and the values you want to allow.

Next you want to update the user by using the findByIdAndUpdate() method from Mongoose. This method’s first argument is the user’s ID, which is provided by the protect middleware function on req.user._id, and then an object of what fields need to be updated.

You also passed in some options to this method. The new: true option will return the user object once those changes have been applied. The runValidators: true will run any validations that are on the User model, for example, check that the email is valid.

If no user can be found your application will return an error, else, a success status will be sent with the user’s updated information.

getMe

The getMe function will allow logged in users to get their information from the database.

Write the following code in your file:

exports.getMe = catchAsync(async (req, res, next) => {
 // Execute the query
 const me = await User.findById(req.user._id);
 
 res.status(200).json({
   status: 'success',
   data: {
     me,
   },
 });
});

This function is getting access to the user ID from the protect middleware on req.user._id. There’s no need to check if a user exists in this function because the user would have to be logged in, therefore existing in the database, in order to access this route. Once the user is found in the database the user object is returned in the response.

deleteMe

The deleteMe function is going to use findByIdAndUpdate() to change the active field to false instead of deleting the user entirely with findByIdAndDelete(). This is considered a soft delete because the user will still be in the database. That way if the user changes their mind you can recover their account. Don't worry though, you’ll write a function soon to delete a user entirely for situations where a user does need to be completely deleted.

Write the following code in your file:

exports.deleteMe = catchAsync(async (req, res, next) => {
 await User.findByIdAndUpdate(req.user._id, {
   active: false,
 });
 
 res.status(204).json({
   status: 'success',
   data: null,
 });
});

updateUser

The updateUser function will only allow users with the admin role to update a user based on their ID.

Write the following code in your file:

exports.updateUser = catchAsync(async (req, res, next) => {
 // Update user document
 const updatedUser = await User.findByIdAndUpdate(req.params.id, req.body, {
   new: true,
   runValidators: true,
 });
 
 if (!updatedUser) {
   return next(new AppError('No user found with that ID', 404));
 }
 
 res.status(201).json({
   status: 'success',
   data: {
     updatedUser,
   },
 });
});

This function is very similar to the updateMe function, but with more control because admins can only access the route. For example, this function doesn’t use the filterObj() function, which lets the data for the function be passed in on the req.body instead of the filteredBody.

It also allows an admin to pass the user ID on a parameter instead of the function getting the user ID from the protect middleware. Similar to other functions, an error is sent if no user is found, otherwise, a success status is sent with the updated user’s data.

getAllUsers

The getAllUsers function will only allow users with the admin role to fetch all users. This function also uses the APIFeatures class so the results can use advanced filtering.

Write the following code in your file:

exports.getAllUsers = catchAsync(async (req, res) => {
 // Execute the query
 const features = new APIFeatures(User.find(), req.query)
   .filter()
   .sort()
   .limitFields()
   .paginate();
 
 const users = await features.query;
 
 res.status(201).json({
   status: 'success',
   data: {
     users,
   },
 });
});

The first argument to the APIFeatures class is the query, which uses the find() method on the User model.

The second argument will be the query string that’s passed in from the request, which Express makes available on req.query. Once the two arguments are passed in you can execute each method in the class to get the filtered results. Last, your application will send back a response with the query results.

getUser

The getUser function will allow only users with the admin role to fetch a user based on an ID.

Write the following code in your file:

exports.getUser = catchAsync(async (req, res, next) => {
 const user = await User.findById(req.params.id);
 
 if (!user) {
   return next(new AppError('No user found with that ID', 404));
 }
 
 res.status(201).json({
   status: 'success',
   data: {
     user,
   },
 });
});

This function uses the findById() on the User model. The previous functions would get access from the user ID from the protect middleware, but this function will get the user ID from a parameter called ID. Express makes this parameter accessible on req.params.id.

If no user is found your application will send an error message, otherwise, your application will send a success message with the user data.

deleteUser

The deleteUser function will allow only users with the admin role to delete a user based on an ID. Unlike the deleteMe function, the deleteUser function will completely remove the user from the database.

Write the following code in your file:

exports.deleteUser = catchAsync(async (req, res, next) => {
 const user = await User.findByIdAndDelete(req.params.id);
 
 if (!user) {
   return next(new AppError('No user found with that ID', 404));
 }
 
 res.status(204).json({
   status: 'success',
   data: null,
 });
});

This function uses the findByIdAndDelete() method on the User model and will get access to the user ID on req.params.id.

If no user is found your application will send an error message, otherwise, your application will send a success message with the user data as null.


User routes

Your user routes will be written in your userRoutes.js file. Open the file and be sure your file looks like this below:

const express = require('express');
const authController = require('./../controllers/authController');
const userController = require('./../controllers/userController');
 
const router = express.Router();
 
router.post('/authLogin', authController.sendAuthLink);
 
router.post('/authVerify/:token', authController.verifyAuthLink);
 
router.get('/isLoggedIn', authController.isLoggedIn);
 
// Protected routes
router.use(authController.protect);
 
router.post('/logout', authController.logout);
 
router.get('/me', userController.getMe);
 
router.patch('/updateMe', userController.updateMe);
 
router.delete('/deleteMe', userController.deleteMe);
 
// Admin routes
router.use(authController.restrictTo('admin'));
 
router.get('/', userController.getAllUsers);
 
router
 .route('/:id')
 .get(userController.getUser)
 .patch(userController.updateUser)
 .delete(userController.deleteUser);
 
module.exports = router;

Under the protected routes you added three new routes for reading, updating, and deleting the currently logged in user. Below these routes you wrote router.use(authController.restrictTo('admin'));. This will ensure that every route under this code will only be accessible for users with the role of admin.

These admin routes will be accessible at ‘/:id’. Using a colon tells Express that this value will be a parameter, which means it’s possible to read the ID value on req.params.id. You also chained the .get(), .patch(), and .delete() methods onto the route because each method uses the same route value of ‘/:id’.


User testing

Now that your user controller and routes are completed you need to test the routes by adding onto your test.rest file. Open that file and add the following code below what you already written:

###
 
# @name getMe
GET {{host}}/users/me HTTP/1.1
 
###
 
# @name updateMe
PATCH {{host}}/users/updateMe HTTP/1.1
Content-Type: application/json
 
{
  "email": "hunter@skillthrive.com"
}
 
###
 
# @name deleteMe
DELETE {{host}}/users/deleteMe HTTP/1.1
 
###
 
# @name getAllUsers
GET {{host}}/users HTTP/1.1
 
###
 
@userId = 5fb55a0cc9e9fc7e629bb8b6
 
# @name getUser
GET {{host}}/users/{{userId}} HTTP/1.1
 
###
 
# @name updateUser
PATCH {{host}}/users/{{userId}} HTTP/1.1
Content-Type: application/json
 
{
  "email": "hello@skillthrive.com"
}
 
###
 
# @name deleteUser
DELETE {{host}}/users/{{userId}} HTTP/1.1

For a deeper understanding of this rest.test file be sure to review the initial setup above.

The first three routes are for signed in users to read, update, and delete themselves. You will need to use the login and verify routes above in order to get and send the required cookies to these three requests. If your auth token has expired you can run the isLoggedIn request to get a new auth token.

The last three routes are for admins to read, update, and delete users with a user ID. You’ll need to go into MongoDB Compass and edit the user you want to have admin rights by changing their role to admin.


Production

Your application is almost ready for production–just a few more things need to be done! The changes are going to be made in your app.js file and will make your application more secure with rate limiting, HTTPS, CORS, XSS protection, and more.

app.js

You’ll use a couple packages to help make it easier to get these features on your application, so go ahead and run the following command in your terminal to install those packages: npm i cors express-rate-limit helmet xss-clean compression express-mongo-sanitize.

Now write the following code in your app.js file (some you already written):

const express = require('express');
const cookieParser = require('cookie-parser');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const helmet = require('helmet');
const mongoSanitize = require('express-mongo-sanitize');
const xss = require('xss-clean');
const compression = require('compression');
 
const userRouter = require('./routes/userRoutes');
const AppError = require('./utils/appError');
 
const app = express();
 
// Cors options
const corsOptions = {
 origin: `${process.env.HOST}`,
 credentials: true,
};
 
// Use cors
app.use(cors(corsOptions));
 
// Security HTTP headers
app.use(helmet());
 
// Limit requests
const limiter = rateLimit({
 max: 500,
 windowMs: 15 * 60 * 1000, // 15 minutes
 message: 'Too many requests from this IP. Please retry in 15 minutes',
});
 
app.use(`/api`, limiter);
 
// Body parser, reading data from body into req.body
app.use(express.json({ limit: '10kb' }));
app.use(express.urlencoded({ extended: true, limit: '10kb' }));
app.use(cookieParser());
 
// Data sanitization against noSQL query injection
app.use(mongoSanitize());
 
// Data sanitization against XSS
app.use(xss());
 
// Compress text sent to client
app.use(compression());
 
// Routes
app.use('/api/v1/users', userRouter);
 
app.all('*', (req, res, next) => {
 next(new AppError(`Can't find ${req.originalUrl} on the server`, 404));
});
 
module.exports = app;

CORS

A request for a resource outside of the origin is known as a cross-origin request. CORS (cross-origin resource sharing) manages cross-origin requests. Typically a request outside the origin would fail, which is why CORS needs to be implemented into your application.

You’ll use the cors package in order to set the required headers for your application. To use the package in Express you wrote app.use(cors(corsOptions));. The corsOptions allow you to pass in various options to your CORS policy.

One of those options you’ll pass in is origin, which will use the HOSTenvironment variable. The other option you need to add is credentials: true, which will allow cookies to be sent on requests.

Read more.

Helmet

The helmet package helps you secure your Express apps by setting various HTTP headers. This is required to run your application in production because HTTPS will encrypt communications between your server and the client so that attackers can't steal data.

Read more.

Rate limiter

The rate limiter is responsible for limiting the number of requests from an IP address. You first defined the limiter options in the limiter constant, calling the limiter() method and passing in values for max, windowMs, and an error message.

Read more.

Mongo sanitize

Object keys starting with a $ or containing a . are reserved for use by MongoDB as operators. Without this sanitization, malicious users could send an object containing a $ operator, or including a ., which could change the context of a database operation. Most notorious is the $where operator, which can execute arbitrary JavaScript on the database.

The best way to prevent this is to sanitize the received data, and remove any offending keys, or replace the characters with a 'safe' one.

Read more.

XSS

XSS attacks occur when an attacker uses a web application to send malicious code, generally in the form of a browser side script, to a different end user. The xss-clean package is a middleware to sanitize user input coming from POST body, GET queries, and url params.

Read more.

Compression

The compression() method is going to compress all your application’s text responses. Better compression means smaller data transfers, which results in faster response times.

Read more.

Route fallback

The route fallback on route.all() will return a new error if no matching routes are found on incoming requests.

Git & GitHub

In this section of the guide you will install Git and push your application to Github. To get started, you first need to install Git on your local machine. This will be different for every operating system, so I’ll refer to the Git docs so you can follow the always up-to-date instructions.

Once Git is installed create a new file in your root directory called .gitignore and write the following code:

node_modules
package-lock.json
Config.env
test.rest

This file will be used by Git to ignore certain files from being committed to your repo. node_modules needs to be ignored because they will be installed by Node when creating your production server, package-lock.json will also be created by Node.

Your config.env file has sensitive information like API keys and secrets–you should never commit these! Instead, you’ll add them securely in Heroku later.

Last, there’s no need to commit test.rest because you’ll only need this in development.

Once you have Git installed on your computer you need to sign up for Github and create a new repository. I also recommend using SSH to set up your GitHub repos, so follow these instructions to get that set up.

Once you have these setup, follow the instructions in GitHub to commit your project to your new GitHub repo, or execute the following commands:

Initialize Git in your project:

git init

Add all untracked files to your local repository:

git add .

Commit the changes with a custom message:

git commit -m “first commit”

Change the branch name to main:

git branch -M main

Add the GitHub repository as a remote origin repository (be sure to replace git@github.com:yourGitHub/your-repo.git with your actual repo):

git remote add origin git@github.com:yourGitHub/your-repo.git

Push the repo to GitHub:

git push -u origin main

Now you should be able to refresh your GitHub repository and see your project files.

Heroku

With your application pushed to GitHub it’s time to create the production build of your application using Heroku. Like Git, installing Heroku will be different for each operating system. So, refer to this always up-to-date guide to get Heroku installed on your local machine.

Now that Heroku is installed go into your terminal and login by writing the following command: heroku login. Follow the instructions in your terminal to open a browser window to complete login.

Once you’re logged in to Heroku you want to create a new project by running (replace your-project-name): heroku create your-project-name. After the project is created you will see a URL print to the terminal pointing to your new remote repository: https://your-project-name.herokuapp.com. Now push your code to this repository by running: git push heroku main.

Your project will start to build and you’ll get a status once the project is done. You can run heroku open to view your new application, however, you’ll notice that your application has crashed. This is because you still need to add environment variables in Heroku.

Environment variables

You can add environment variables via the CLI, but I prefer adding them from within the Heroku dashboard. First, head to Heroku and login. Then navigate to your project > Settings > Reveal Config Vars.

Now you can add your environment variables one-by-one, but there’s no need to add the NODE_ENV and PORT variables because Heroku takes care of these.

Remember to use DATABASE_PROD instead of DATABASE_DEV, which should be set to your production MongoDB database. Your HOST variable will be the URL where your frontend code is hosted. Ideally you would have this ready for production, but for now you can set this to an empty string.

Once your environment variables are added you need to restart your Dyno by going to the top-right and clicking on More > Restart all Dynos. Now head to More > View logs to see your applications logs. If your application is successfully running you should see App running on port… and DB Connection Successful printing to your logs.

By default your Heroku project will be on a free tier, which will go to sleep after a certain amount of inactivity. You’ll need to switch to a paid Dyno if you want a server that’s always running. No worries, the pricing starts at just $7 a month.

To do this, head to your project in Heroku and under the Overview tab select Configure Dynos. From the new page click on Change Dyno Type and select Hobby or your required Dyno level.

Testing

You can test your new application from the same test.rest file, but change the host variable to your new application URL.