Customized Remote Work Solutions From the World’s Largest Fully Remote CompanyCustomized Remote Work SolutionsLearn More
Technology
13 minute read

Creating a Secure REST API in Node.js

Marcos has 15+ years in IT and development. His passions include REST architecture, Agile development methodology, and JS.

Application programming interfaces (APIs) are everywhere. They enable software to communicate with other pieces of software—internal or external—consistently, which is a key ingredient in scalability, not to mention reusability.

It’s quite common nowadays for online services to have public-facing APIs. These enable other developers to easily integrate features like social media logins, credit card payments, and behavior tracking. The de facto standard they use for this is called REpresentational State Transfer (REST).

While a multitude of platforms and programming languages can be used for the task—e.g., ASP.NET Core, Laravel (PHP), or Bottle (Python)—in this tutorial, we’ll build a basic but secure REST API back end using the following stack:

  • Node.js, which the reader should already have some familiarity with
  • Express, which vastly simplifies building out common web server tasks under Node.js and is standard fare in building a REST API back end
  • Mongoose, which will connect our back end to a MongoDB database

Developers following this tutorial should also be comfortable with the terminal (or command prompt).

Note: We won’t cover a front-end codebase here, but the fact that our back end is written in JavaScript makes it convenient to share code—object models, for instance—throughout the full stack.

Anatomy of a REST API

REST APIs are used to access and manipulate data using a common set of stateless operations. These operations are integral to the HTTP protocol and represent essential create, read, update, and delete (CRUD) functionality, although not in a clean one-to-one manner:

  • POST (create a resource or generally provide data)
  • GET (retrieve an index of resources or an individual resource)
  • PUT (create or replace a resource)
  • PATCH (update/modify a resource)
  • DELETE (remove a resource)

Using these HTTP operations and a resource name as an address, we can build a REST API by creating an endpoint for each operation. And by implementing the pattern, we will have a stable and easily understandable foundation enabling us to evolve the code rapidly and maintain it afterward. As mentioned before, the same foundation will be used to integrate third-party features, most of which likewise use REST APIs, making such integration faster.

For now, let’s start creating our secure REST API using Node.js!

In this tutorial, we are going to create a pretty common (and very practical) REST API for a resource called users.

Our resource will have the following basic structure:

  • id (an auto-generated UUID)
  • firstName
  • lastName
  • email
  • password
  • permissionLevel (what is this user allowed to do?)

And we will create the following operations for that resource:

  • POST on the endpoint /users (create a new user)
  • GET on the endpoint /users (list all users)
  • GET on the endpoint /users/:userId (get a specific user)
  • PATCH on the endpoint /users/:userId (update the data for a specific user)
  • DELETE on the endpoint /users/:userId (remove a specific user)

We will also be using JSON web tokens (JWTs) for access tokens. To that end, we will create another resource called auth that will expect a user’s email and password and, in return, will generate the token used for authentication on certain operations. (Dejan Milosevic’s great article on JWT for secure REST applications in Java goes into further detail about this; the principles are the same.) ## REST API Tutorial Setup

First of all, make sure that you have the latest Node.js version installed. For this article, I’ll be using version 14.9.0; it may also work on older versions.

Next, make sure that you have MongoDB installed. We won’t explain the specifics of Mongoose and MongoDB that are used here, but to get the basics running, simply start the server in interactive mode (i.e., from the command line as mongo) rather than as a service. That’s because, at one point in this tutorial, we’ll need to interact with MongoDB directly rather than via our Node.js code.

Note: With MongoDB, there’s no need to create a specific database like there might be in some RDBMS scenarios. The first insert call from our Node.js code will trigger its creation automatically.

This tutorial does not contain all of the code necessary for a working project. It’s intended instead that you clone the companion repo and simply follow along the highlights as you read through—but you can also copy in specific files and snippets from the repo as needed, if you prefer.

Navigate to the resulting rest-api-tutorial/ folder in your terminal. You’ll see that our project contains three module folders:

  • common (handling all shared services, and information shared between user modules)
  • users (everything regarding users)
  • auth (handling JWT generation and the login flow)

Now, run npm install (or yarn if you have it.)

Congratulations, you now have all of the dependencies and setup required to run our simple REST API back end.

Creating the User Module

We will be using Mongoose, an object data modeling (ODM) library for MongoDB, to create the user model within the user schema.

First, we need to create the Mongoose schema in /users/models/users.model.js:

const userSchema = new Schema({
   firstName: String,
   lastName: String,
   email: String,
   password: String,
   permissionLevel: Number
});

Once we define the schema, we can easily attach the schema to the user model.

const userModel = mongoose.model('Users', userSchema);

After that, we can use this model to implement all the CRUD operations that we want within our Express endpoints.

Let’s start with the “create user” operation by defining the route in users/routes.config.js:

app.post('/users', [
   UsersController.insert
]);

This is pulled into our Express app in the main index.js file. The UsersController object is imported from our controller, where we hash the password appropriately, defined in /users/controllers/users.controller.js:

exports.insert = (req, res) => {
   let salt = crypto.randomBytes(16).toString('base64');
   let hash = crypto.createHmac('sha512',salt)
                                    .update(req.body.password)
                                    .digest("base64");
   req.body.password = salt + "$" + hash;
   req.body.permissionLevel = 1;
   UserModel.createUser(req.body)
       .then((result) => {
           res.status(201).send({id: result._id});
       });
};

At this point, we can test our Mongoose model by running the server (npm start) and sending a POST request to /users with some JSON data:

{
   "firstName" : "Marcos",
   "lastName" : "Silva",
   "email" : "[email protected]",
   "password" : "s3cr3tp4sswo4rd"
}

There are several tools you can use for this. Insomnia (covered below) and Postman are popular GUI tools, and curl is a common CLI choice. You can even just use JavaScript, e.g., from your browser’s built-in development tools console:

fetch('http://localhost:3600/users', {
        method: 'POST',
        headers: {
            "Content-type": "application/json"
        },
        body: JSON.stringify({
            "firstName": "Marcos",
            "lastName": "Silva",
            "email": "[email protected]",
            "password": "s3cr3tp4sswo4rd"
        })
    })
    .then(function(response) {
        return response.json();
    })
    .then(function(data) {
        console.log('Request succeeded with JSON response', data);
    })
    .catch(function(error) {
        console.log('Request failed', error);
    });

At this point, the result of a valid post will be just the id from the created user: { "id": "5b02c5c84817bf28049e58a3" }. We need to also add the createUser method to the model in users/models/users.model.js:

exports.createUser = (userData) => {
    const user = new User(userData);
    return user.save();
};

All set, now we need to see if the user exists. For that, we are going to implement the “get user by id” feature for the following endpoint: users/:userId.

First, we create a route in /users/routes/config.js:

app.get('/users/:userId', [
    UsersController.getById
]);

Then, we create the controller in /users/controllers/users.controller.js:

exports.getById = (req, res) => {
   UserModel.findById(req.params.userId).then((result) => {
       res.status(200).send(result);
   });
};

And finally, add the findById method to the model in /users/models/users.model.js:

exports.findById = (id) => {
    return User.findById(id).then((result) => {
        result = result.toJSON();
        delete result._id;
        delete result.__v;
        return result;
    });
};

The response will be like this:

{
   "firstName": "Marcos",
   "lastName": "Silva",
   "email": "[email protected]",
   "password": "Y+XZEaR7J8xAQCc37nf1rw==$p8b5ykUx6xpC6k8MryDaRmXDxncLumU9mEVabyLdpotO66Qjh0igVOVerdqAh+CUQ4n/E0z48mp8SDTpX2ivuQ==",
   "permissionLevel": 1,
   "id": "5b02c5c84817bf28049e58a3"
}

Note that we can see the hashed password. For this tutorial, we are showing the password, but the obvious best practice is never to reveal the password, even if it has been hashed. Another thing we can see is the permissionLevel, which we will use to handle the user permissions later on.

Repeating the pattern laid out above, we can now add the functionality to update the user. We will use the PATCH operation since it will enable us to send only the fields we want to change. The route will, therefore, be PATCH to /users/:userid, and we’ll be sending any fields we want to change. We will also need to implement some extra validation since changes should be restricted to the user in question or an admin, and only an admin should be able to change the permissionLevel. We’ll skip that for now and get back to it once we implement the auth module. For now, our controller will look like this:

exports.patchById = (req, res) => {
   if (req.body.password){
       let salt = crypto.randomBytes(16).toString('base64');
       let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64");
       req.body.password = salt + "$" + hash;
   }
   UserModel.patchUser(req.params.userId, req.body).then((result) => {
           res.status(204).send({});
   });
};

By default, we will send an HTTP code 204 with no response body to indicate that the request was successful.

And we’ll need to add the patchUser method to the model:

exports.patchUser = (id, userData) => {
    return User.findOneAndUpdate({
        _id: id
    }, userData);
};

The user list will be implemented as a GET at /users/ by the following controller:

exports.list = (req, res) => {
   let limit = req.query.limit && req.query.limit <= 100 ? parseInt(req.query.limit) : 10;
   let page = 0;
   if (req.query) {
       if (req.query.page) {
           req.query.page = parseInt(req.query.page);
           page = Number.isInteger(req.query.page) ? req.query.page : 0;
       }
   }
   UserModel.list(limit, page).then((result) => {
       res.status(200).send(result);
   })
};

The corresponding model method will be:

exports.list = (perPage, page) => {
    return new Promise((resolve, reject) => {
        User.find()
            .limit(perPage)
            .skip(perPage * page)
            .exec(function (err, users) {
                if (err) {
                    reject(err);
                } else {
                    resolve(users);
                }
            })
    });
};

The resulting list response will have the following structure:

[
   {
       "firstName": "Marco",
       "lastName": "Silva",
       "email": "[email protected]",
       "password": "z4tS/DtiH+0Gb4J6QN1K3w==$al6sGxKBKqxRQkDmhnhQpEB6+DQgDRH2qr47BZcqLm4/fphZ7+a9U+HhxsNaSnGB2l05Oem/BLIOkbtOuw1tXA==",
       "permissionLevel": 1,
       "id": "5b02c5c84817bf28049e58a3"
   },
   {
       "firstName": "Paulo",
       "lastName": "Silva",
       "email": "[email protected]",
       "password": "wTsqO1kHuVisfDIcgl5YmQ==$cw7RntNrNBNw3MO2qLbx959xDvvrDu4xjpYfYgYMxRVDcxUUEgulTlNSBJjiDtJ1C85YimkMlYruU59rx2zbCw==",
       "permissionLevel": 1,
       "id": "5b02d038b653603d1ca69729"
   }
]

And the last part to be implemented is the DELETE at /users/:userId.

Our controller for deletion will be:

exports.removeById = (req, res) => {
   UserModel.removeById(req.params.userId)
       .then((result)=>{
           res.status(204).send({});
       });
};

Same as before, the controller will return HTTP code 204 and no content body as confirmation.

The corresponding model method should look like this:

exports.removeById = (userId) => {
    return new Promise((resolve, reject) => {
        User.deleteMany({_id: userId}, (err) => {
            if (err) {
                reject(err);
            } else {
                resolve(err);
            }
        });
    });
};

We now have all the necessary operations for manipulating the user resource, and we’re done with the user controller. The main idea of this code is to give you the core concepts of using the REST pattern. We’ll need to return to this code to implement some validations and permissions to it, but first, we’ll need to start building our security. Let’s create the auth module.

Creating the Auth Module

Before we can secure the users module by implementing the permission and validation middleware, we’ll need to be able to generate a valid token for the current user. We will generate a JWT in response to the user providing a valid email and password. JWT is a remarkable JSON web token that you can use to have the user securely make several requests without validating repeatedly. It usually has an expiration time, and a new token is recreated every few minutes to keep the communication secure. For this tutorial, though, we will forgo refreshing the token and keep it simple with a single token per login.

First, we will create an endpoint for POST requests to /auth resource. The request body will contain the user email and password:

{
   "email" : "[email protected]",
   "password" : "s3cr3tp4sswo4rd2"
}

Before we engage the controller, we should validate the user in /authorization/middlewares/verify.user.middleware.js:

exports.isPasswordAndUserMatch = (req, res, next) => {
   UserModel.findByEmail(req.body.email)
       .then((user)=>{
           if(!user[0]){
               res.status(404).send({});
           }else{
               let passwordFields = user[0].password.split('$');
               let salt = passwordFields[0];
               let hash = crypto.createHmac('sha512', salt).update(req.body.password).digest("base64");
               if (hash === passwordFields[1]) {
                   req.body = {
                       userId: user[0]._id,
                       email: user[0].email,
                       permissionLevel: user[0].permissionLevel,
                       provider: 'email',
                       name: user[0].firstName + ' ' + user[0].lastName,
                   };
                   return next();
               } else {
                   return res.status(400).send({errors: ['Invalid email or password']});
               }
           }
       });
};

Having done that, we can move on to the controller and generate the JWT:

exports.login = (req, res) => {
   try {
       let refreshId = req.body.userId + jwtSecret;
       let salt = crypto.randomBytes(16).toString('base64');
       let hash = crypto.createHmac('sha512', salt).update(refreshId).digest("base64");
       req.body.refreshKey = salt;
       let token = jwt.sign(req.body, jwtSecret);
       let b = Buffer.from(hash);
       let refresh_token = b.toString('base64');
       res.status(201).send({accessToken: token, refreshToken: refresh_token});
   } catch (err) {
       res.status(500).send({errors: err});
   }
};

Even though we won’t be refreshing the token in this tutorial, the controller has been set up to enable such generation to make it easier to implement it in subsequent development.

All we need now is to create the route and invoke the appropriate middleware in /authorization/routes.config.js:

    app.post('/auth', [
        VerifyUserMiddleware.hasAuthValidFields,
        VerifyUserMiddleware.isPasswordAndUserMatch,
        AuthorizationController.login
    ]);

The response will contain the generated JWT in the accessToken field:

{
   "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOiI1YjAyYzVjODQ4MTdiZjI4MDQ5ZTU4YTMiLCJlbWFpbCI6Im1hcmNvcy5oZW5yaXF1ZUB0b3B0YWwuY29tIiwicGVybWlzc2lvbkxldmVsIjoxLCJwcm92aWRlciI6ImVtYWlsIiwibmFtZSI6Ik1hcmNvIFNpbHZhIiwicmVmcmVzaF9rZXkiOiJiclhZUHFsbUlBcE1PakZIRG1FeENRPT0iLCJpYXQiOjE1MjY5MjMzMDl9.mmNg-i44VQlUEWP3YIAYXVO-74803v1mu-y9QPUQ5VY",
   "refreshToken": "U3BDQXBWS3kyaHNDaGJNanlJTlFkSXhLMmFHMzA2NzRsUy9Sd2J0YVNDTmUva0pIQ0NwbTJqOU5YZHgxeE12NXVlOUhnMzBWMGNyWmdOTUhSaTdyOGc9PQ=="
}

Having created the token, we can use it inside the Authorization header using the form Bearer ACCESS_TOKEN.

Creating Permissions and Validations Middleware

The first thing we should define is who can use the users resource. These are the scenarios that we’ll need to handle:

  • Public for creating users (registration process). We will not use JWT for this scenario.
  • Private for the logged-in user and for admins to update that user.
  • Private for admin only for removing user accounts.

Having identified these scenarios, we will first require a middleware that always validates the user if they are using a valid JWT. The middleware in /common/middlewares/auth.validation.middleware.js can be as simple as:

exports.validJWTNeeded = (req, res, next) => {
    if (req.headers['authorization']) {
        try {
            let authorization = req.headers['authorization'].split(' ');
            if (authorization[0] !== 'Bearer') {
                return res.status(401).send();
            } else {
                req.jwt = jwt.verify(authorization[1], secret);
                return next();
            }
        } catch (err) {
            return res.status(403).send();
        }
    } else {
        return res.status(401).send();
    }
}; 

We will use HTTP error codes for handling request errors:

  • HTTP 401 for an invalid request
  • HTTP 403 for a valid request with an invalid token, or valid token with invalid permissions

We can use the bitwise AND operator (bitmasking) to control the permissions. If we set each required permission as a power of 2, we can treat each bit of the 32-bit integer as a single permission. An admin can then have all permissions by setting their permission value to 2147483647. That user could then have access to any route. As another example, a user whose permission value was set to 7 would have permissions to the roles marked with bits for values 1, 2, and 4 (two to the power of 0, 1, and 2).

The middleware for that would look like this:

exports.minimumPermissionLevelRequired = (required_permission_level) => {
   return (req, res, next) => {
       let user_permission_level = parseInt(req.jwt.permission_level);
       let user_id = req.jwt.user_id;
       if (user_permission_level & required_permission_level) {
           return next();
       } else {
           return res.status(403).send();
       }
   };
};

The middleware is generic. If the user permission level and the required permission level coincide in at least one bit, the result will be greater than zero, and we can let the action proceed; otherwise, the HTTP code 403 will be returned.

Now, we need to add the authentication middleware to the user’s module routes in /users/routes.config.js:

app.post('/users', [
   UsersController.insert
]);
app.get('/users', [
   ValidationMiddleware.validJWTNeeded,
   PermissionMiddleware.minimumPermissionLevelRequired(PAID),
   UsersController.list
]);
app.get('/users/:userId', [
   ValidationMiddleware.validJWTNeeded,
   PermissionMiddleware.minimumPermissionLevelRequired(FREE),
   PermissionMiddleware.onlySameUserOrAdminCanDoThisAction,
   UsersController.getById
]);
app.patch('/users/:userId', [
   ValidationMiddleware.validJWTNeeded,
   PermissionMiddleware.minimumPermissionLevelRequired(FREE),
   PermissionMiddleware.onlySameUserOrAdminCanDoThisAction,
   UsersController.patchById
]);
app.delete('/users/:userId', [
   ValidationMiddleware.validJWTNeeded,
   PermissionMiddleware.minimumPermissionLevelRequired(ADMIN),
   UsersController.removeById
]);

This concludes the basic development of our REST API. All that remains to be done is to test it all out.

Running and Testing with Insomnia

Insomnia is a decent REST client with a good free version. The best practice is, of course, to include code tests and implement proper error reporting in the project, but third-party REST clients are great for testing and implementing third-party solutions when error reporting and debugging the service is not available. We’ll be using it here to play the role of an application and get some insight into what is going on with our API.

To create a user, we just need to POST the required fields to the appropriate endpoint and store the generated ID for subsequent use.

Request with the appropriate data for creating a user

The API will respond with the user ID:

Confirmation response with userID

We can now generate the JWT using the /auth/ endpoint:

Request with login data

We should get a token as our response:

Confirmation containing the corresponding JSON Web Token

Grab the accessToken, prefix it with Bearer (remember the space), and add it to the request headers under Authorization:

Setting up the headers to transfer contain the authenticating JWT

If we don’t do this now that we have implemented the permissions middleware, every request other than registration would be returning HTTP code 401. With the valid token in place, though, we get the following response from /users/:userId:

Response listing the data for the indicated user

Also, as was mentioned before, we are displaying all fields, for educational purposes and for sake of simplicity. The password (hashed or otherwise) should never be visible in the response.

Let’s try to get a list of users:

Request for a list of all users

Surprise! We get a 403 response.

Action refused due to lack of appropriate permission level

Our user does not have the permissions to access this endpoint. We will need to change the permissionLevel of our user from 1 to 7 (or even 5 would do, since our free and paid permissions levels are represented as 1 and 4, respectively.) We can do this manually in MongoDB, at its interactive prompt, like this (with the ID changed to your local result):

db.users.update({"_id" : ObjectId("5b02c5c84817bf28049e58a3")},{$set:{"permissionLevel":5}})

Then, we need to generate a new JWT.

After that is done, we get the proper response:

Response with all users and their data

Next, let’s test the update functionality by sending a PATCH request with some fields to our /users/:userId endpoint:

Request containing partial data to be updated

We expect a 204 response as confirmation of a successful operation, but we can request the user once again to verify.

Response after successful change

Finally, we need to delete the user. We’ll need to create a new user as described above (don’t forget to note the user ID) and make sure that we have the appropriate JWT for an admin user. The new user will need their permissions set to 2053 (that’s 2048—ADMIN—plus our earlier 5) to be able to also perform the delete operation. With that done and a new JWT generated, we’ll have to update our Authorization request header:

Request setup for deleting a user

Sending a DELETE request to /users/:userId, we should get a 204 response as confirmation. We can, again, verify by requesting /users/ to list all existing users.

Next Steps for Your REST API

With the tools and methods covered in this tutorial, you should now be able to create simple and secure REST APIs on Node.js. A lot of best practices that are not essential to the process were skipped, so don’t forget to:

  • Implement proper validations (e.g., make sure that user email is unique)
  • Implement unit testing and error reporting
  • Prevent users from changing their own permission level
  • Prevent admins from removing themselves
  • Prevent disclosure of sensitive information (e.g., hashed passwords)
  • Move the JWT secret from common/config/env.config.js to an off-repo, non-environment-based secret distribution mechanism

One final exercise for the reader can be to convert the codebase from its use of JavaScript promises over to the async/await technique.

For those of you who might be interested, there is now also a TypeScript version of the project available.