We're live on Product Hunt! 😻

Iconcrate - Animated Lottie icons for websites and apps | Product Hunt

Passwordless login with Node

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

Downloads

Unlock the full guide for $25

Lifetime access and updates

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.

Unlock the full guide for $25

Lifetime access and updates