DEV Community

SnykSec for Snyk

Posted on • Originally published at snyk.io on

How to build a secure API gateway in Node.js

Microservices offer significant advantages compared to monoliths. You can scale the development more easily and have precise control over scaling infrastructure. Additionally, the ability to make many minor updates and incremental rollouts significantly reduces the time to market.

Despite these benefits, microservices architecture presents a problem — the inability to access its services externally. Fortunately, an API gateway can resolve this issue.

API gateways can provide a clear path for your front-end applications (e.g., websites and native apps) to all of your back-end functionality. They can act as aggregators for the microservices and as middleware to handle common concerns, such as authentication and authorization. Additionally, API gateways are convenient for service collection and unification, combining output formats like XML and JSON into a single format.

API gateways represent a crucial instrument for security and reliability, providing session management, input sanitation, distributed denial of service (DDoS) attack protection, rate limiting, and log transactions.

In this article, we’ll build a secure API gateway from scratch using only Node.js and a couple of open source packages. All you need is basic knowledge of your terminal, Node.js version 14 or later, and JavaScript. You can find the final project code here.

Let’s get started!

Creating our API gateway

Although we could write a web server in Node.js from scratch, here we’ll use an existing framework for the heavy lifting. Arguably, the most popular choice for Node.js is Express, as it’s lightweight, reasonably fast, and easily extensible. Plus, you can integrate almost any necessary package into Express.

To begin, let’s start a new Node.js project and install Express:

npm init -y
npm install express --save
Enter fullscreen mode Exit fullscreen mode

With these two commands, we created a new Node.js project using the default settings. We also installed the express package. At this point, we should find a package.json file and a node_modules directory in our project directory.

Now we need to create a file called index.js, which we’ll place adjacent to package.json. The file should have the following initial content:

const express = require("express");

const app = express();
const port = 3000;

app.get("/", (req, res) => {
  const { name = "user" } = req.query;
  res.send(`Hello ${name}!`);
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});
Enter fullscreen mode Exit fullscreen mode

This setup should be sufficient to run a basic web server with one endpoint. You can start it by running the following command in your terminal:

node index.js
Enter fullscreen mode Exit fullscreen mode

Now, navigate to http://localhost:3000 from your browser. You should see “Hello user!” displayed on the screen. By appending a query to the URL, such as ?name=King, you should see “Hello King!”

The next step is to set up authentication. For this, we use the express-session package. This package uses a cookie to help us to guard endpoints. Users who don’t have a valid session cookie will receive a relevant response containing either the HTTP 401 (Unauthorized) or 403 (Forbidden) status code.

We’ll also keep our password a little more secure by storing it in an environment variable. We’ll use the dotenv package to load our SESSION_SECRET variable from a .env file into process.env.

First, make sure to stop the previous process by entering Ctrl + C in your console. Then, install express-session and dotenv with the following command:

npm install express-session dotenv --save
Enter fullscreen mode Exit fullscreen mode

Next, create a .env file in your project’s top-level directory, and add the following code to it:

SESSION_SECRET=`<your_secret>`
Enter fullscreen mode Exit fullscreen mode

By convention, environment variables are named in uppercase, and make sure to use a unique and random secret — a strong password will work.

Our additions to index.js should look like this (we’ll use this fragment later when we compose together the solution):

require("dotenv").config();

const session = require("express-session");

const secret = process.env.SESSION_SECRET;
const store = new session.MemoryStore();
const protect = (req, res, next) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    res.sendStatus(401);
  } else {
    next();
  }
};

app.use(
  session({
    secret,
    resave: false,
    saveUninitialized: true,
    store,
  })
);
Enter fullscreen mode Exit fullscreen mode

With this setup, we get memory-based session handling that you can use to assert endpoints. Let’s use it with a new endpoint and provide some dedicated endpoints for login and logout. Add the following code to the end of index.js:

app.get("/login", (req, res) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    req.session.authenticated = true;
    res.send("Successfully authenticated");
  } else {
    res.send("Already authenticated");
  }
});

app.get("/logout", protect, (req, res) => {
  req.session.destroy(() => {
    res.send("Successfully logged out");
  });
});

app.get("/protected", protect, (req, res) => {
  const { name = "user" } = req.query;
  res.send(`Hello ${name}!`);
});
Enter fullscreen mode Exit fullscreen mode

Now, go ahead and run the code again to try this out for yourself by using the node index.js command.

So how does this work? Here’s a simple usage flow:

  • Navigating to / will work.
  • Navigating to /protected will return a 401 (Unauthorized) HTTP status code.
  • Navigating to /login will automatically log you in, displaying “Successfully authenticated.”
  • Navigating to /login again will display “Already authenticated.”
  • When you access /protected, the page will now work.
  • Navigating to /logout resets the session and displays “Successfully logged out.”
  • Then, /protected again becomes inaccessible, displaying a 401 status code.
  • Revisiting /logout will also return a 401 status code.

By using the protect middleware before the request handler, we can guard an endpoint by ensuring a user has logged in. Additionally, we have the /login and /logout endpoints to reinforce these capabilities.

Let’s exit the process with Ctrl + C again and, before we discuss the heart of our API gateway, let’s explore logging and proper rate limiting.

Rate limiting

Rate limiting ensures that your API can only be accessed a certain number of times within a specified time interval. This protects it from bandwidth exhaustion due to organic traffic and DoS attacks. You can configure rate limits to apply to traffic originating from specific sources, and there are many different ways to calculate and enforce the time window within which requests will be processed.

First, we need to install a rate limiting package:

npm install express-rate-limit --save
Enter fullscreen mode Exit fullscreen mode

Then, we configure the package. Make sure to insert this code in the index.js file before any routes you want limited:

const rateLimit = require("express-rate-limit");

app.use(
  rateLimit({
    windowMs: 15 * 60 * 1000, // 15 minutes
    max: 5, // 5 calls
  })
);
Enter fullscreen mode Exit fullscreen mode

Now we can test this by restarting the server using the node index.js command and hitting our initial endpoint several times. Once you’ve hit the five-request limit within a 15-minute timeframe, you should see a message that says, “Too many requests, please try again later.” By default, express-rate-limit also sends back the correct 429 (Too Many Requests) HTTP status code.

Before moving to the next step, make sure to exit the process to reset your limit.

Logging

To establish logging, we can use winston, which also comes with dedicated middleware for Express: express-winston. Furthermore, we may want to log the response times of the endpoints for closer inspection later. One helpful package for this is response-time. Let’s install these packages.

npm install winston express-winston response-time --save
Enter fullscreen mode Exit fullscreen mode

The integration of these packages is straightforward. Add the following code to index.js before any code you want logged — so it’s best to insert it before the rate limiting code:

const winston = require("winston");
const expressWinston = require("express-winston");
const responseTime = require("response-time");

app.use(responseTime());

app.use(
  expressWinston.logger({
    transports: [new winston.transports.Console()],
    format: winston.format.json(),
    statusLevels: true,
    meta: false,
    msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
    expressFormat: true,
    ignoreRoute() {
      return false;
    },
  })
);
Enter fullscreen mode Exit fullscreen mode

With this configuration, we get some additional output. Now, when starting the server and going to /, the console should look as follows:

Server running at http://localhost:3000
{"level":"info","message":"GET / 200 8ms","meta":{}}
{"level":"warn","message":"GET /favicon.ico 404 3ms","meta":{}}
Enter fullscreen mode Exit fullscreen mode

When you’re ready to continue, go ahead and exit the process using Ctrl + C.

Cross-origin resource sharing (CORS)

Because we’re using our API gateway as the layer between the front-end and back-end services, we’ll handle our cross-origin resource sharing (CORS) here. CORS is a browser-based security mechanism that ensures that the back end will accept certain cross-origin resource requests (for example, requests from www.company.com to api.company.com).

To achieve this, we make a special request before the primary request. This request uses the OPTION HTTP verb and expects special headers in the response to allow or forbid the subsequent requests.

To enable CORS in our gateway, we can install the cors package. At this point, we can also add more security headers using a helpful package called helmet. We can install both packages using the code below:

npm install cors helmet --save
Enter fullscreen mode Exit fullscreen mode

We can integrate them like this:

const cors = require("cors");
const helmet = require("helmet");

app.use(cors());
app.use(helmet());
Enter fullscreen mode Exit fullscreen mode

This configuration allows all domains to access the API. We could also set more fine-grained configurations, but the one above is sufficient for now.

Proxying

An API gateway primarily forwards requests to other dedicated microservices to route business logic requests and other HTTP requests, so we need a package to handle this forwarding: a proxy. We’ll use http-proxy-middleware, which you can install using the code below:

npm install http-proxy-middleware --save
Enter fullscreen mode Exit fullscreen mode

Now, we’ll add it, along with a new endpoint:

const { createProxyMiddleware } = require("http-proxy-middleware");

app.use(
  "/search",
  createProxyMiddleware({
    target: "http://api.duckduckgo.com/",
    changeOrigin: true,
    pathRewrite: {
      [`^/search`]: "",
    },
  })
);
Enter fullscreen mode Exit fullscreen mode

You can test this by starting your server and accessing /search?q=x&format=json, which returns the results obtained from proxying the request to http://api.duckduckgo.com/. If you do run the code, make sure to exit the process when you’re finished so you can go ahead with the final changes.

Configuration

Now that we have all the pieces, let’s configure them to work together. To do this, let’s create a new file called config.js in the same directory as the other files:

require("dotenv").config();

exports.serverPort = 3000;
exports.sessionSecret = process.env.SESSION_SECRET;
exports.rate = {
  windowMs: 5 * 60 * 1000,
  max: 100,
};
exports.proxies = {
  "/search": {
    protected: true,
    target: "http://api.duckduckgo.com/",
    changeOrigin: true,
    pathRewrite: {
      [`^/search`]: "",
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

Here, we created the essential configuration of our API gateway. We set its port, cookie session encryption key, and the different endpoints to proxy. The options for each proxy match the options for the createProxyMiddleware function, with the addition of the protected key.

Let’s also go ahead and update the secret and port declarations, as well as the call to rateLimit in index.js, to point to the values we define in our config file:

const secret = config.sessionSecret;
...
const port = config.serverPort;
...
app.use(rateLimit(config.rate));
Enter fullscreen mode Exit fullscreen mode

We also need to remove the code for which we’ve moved the functionality to config.js, as well as our initial endpoint testing code. Go ahead and remove the following code from index.js:

require("dotenv").config();
...
app.use(
 "/search",
 createProxyMiddleware({
   target: "http://api.duckduckgo.com/",
   changeOrigin: true,
   pathRewrite: {
    [`^/search`]: "",
   },
 })
);
...
app.get("/", (req, res) => {
 const { name = "user" } = req.query;
 res.send(`Hello ${name}!`);
});
...
app.get("/protected", protect, (req, res) => {
 const { name = "user" } = req.query;
 res.send(`Hello ${name}!`);
});
Enter fullscreen mode Exit fullscreen mode

Final app

Let’s walk through the contents of our final index.js file, which has a couple small additions:

// import all the required packages
const cors = require("cors");
const express = require("express");
const session = require("express-session");
const rateLimit = require("express-rate-limit");
const expressWinston = require("express-winston");
const helmet = require("helmet");
const { createProxyMiddleware } = require("http-proxy-middleware");
const responseTime = require("response-time");
const winston = require("winston");
const config = require("./config");

// configure the application
const app = express();
const port = config.serverPort;
const secret = config.sessionSecret;
const store = new session.MemoryStore();
Enter fullscreen mode Exit fullscreen mode

We’ll add some logic to check the protected property values of the proxies we list in config.js. If they’re set to false, the alwaysAllow function we define here passes control through to the next handler:

const alwaysAllow = (_1, _2, next) => {
  next();
};
const protect = (req, res, next) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    res.sendStatus(401);
  } else {
    next();
  }
};
Enter fullscreen mode Exit fullscreen mode

Some legacy server technologies also include nonfunctional server description data in the HTTP header. To keep our API secure, we’ll unset this to give away less information to potentially malicious actors:

app.disable("x-powered-by");

app.use(helmet());

app.use(responseTime());

app.use(
  expressWinston.logger({
    transports: [new winston.transports.Console()],
    format: winston.format.json(),
    statusLevels: true,
    meta: false,
    level: "debug",
    msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
    expressFormat: true,
    ignoreRoute() {
      return false;
    },
  })
);

app.use(cors());

app.use(rateLimit(config.rate));

app.use(
  session({
    secret,
    resave: false,
    saveUninitialized: true,
    store,
  })
);

app.get("/login", (req, res) => {
  const { authenticated } = req.session;

  if (!authenticated) {
    req.session.authenticated = true;
    res.send("Successfully authenticated");
  } else {
    res.send("Already authenticated");
  }
});
Enter fullscreen mode Exit fullscreen mode

Next, we iterate over the proxies listed in config.js, check the value of their protected parameter, call either the protect or alwaysAllow function we defined earlier, and append a new proxy for each configured entry:

Object.keys(config.proxies).forEach((path) => {
  const { protected, ...options } = config.proxies[path];
  const check = protected ? protect : alwaysAllow;
  app.use(path, check, createProxyMiddleware(options));
});

app.get("/logout", protect, (req, res) => {
  req.session.destroy(() => {
    res.send("Successfully logged out");
  });
});

app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});
Enter fullscreen mode Exit fullscreen mode

This code is already quite flexible, but uses standard HTTP instead of HTTPS. In many cases, this might be sufficient. For example, in Kubernetes, the code might be behind an NGINX ingress, which handles TLS and is responsible for the certificate.

However, sometimes we might want to expose the running code directly to the internet. In this case, we would need a valid certificate.

A certificate registry like Let’s Encrypt provides many ways to enable HTTPS. You can use a client like Greenlock Express to automatically manage your certificates or implement a more fine-grained approach using a client like the Publishlab acme-client.

const { readFileSync } = require('fs');
const { createServer } = require('https');

// assumes that the key and certificate are stored in a "cert" directory
const credentials = {
  key: readFileSync('cert/server.key', 'utf8'),
  cert: readFileSync('cert/server.crt', 'utf8'),
};

// here we use the express "app" to attach to the created server
const httpsServer = createServer(credentials, app);
Enter fullscreen mode Exit fullscreen mode

Note that this will require sudo, but it’s assumed anyway, as the only reason for having HTTPS here is that the server is exposed directly. Therefore, we need 443 instead of a public port, such as 8443.

// use standard HTTPS port
httpsServer.listen(443);
Enter fullscreen mode Exit fullscreen mode

Conclusion

We’ve now finished building a secure API gateway — from scratch — using Node.js. We implemented features like session management, throttling, proxies, logging, and CORS. We also learned how to configure Node.js to use TLS and enforce HTTPS access.

Using an API gateway brings some significant advantages for larger back-end infrastructures. By hiding the back-end services behind an API gateway, we can easily enforce common security protocols, making our services significantly simpler and protecting them from everyday vulnerabilities.

Free developer security training

Learn how to easily prevent DoS attacks, logging misconfigurations, rate limiting issues, and more.

Start learning

Top comments (0)