How to build a secure API gateway in Node.js
28 de dezembro de 2022
0 minutos de leituraMicroservices 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:
1npm init -y
2npm install express --save
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:
1const express = require("express");
2
3const app = express();
4const port = 3000;
5
6app.get("/", (req, res) => {
7 const { name = "user" } = req.query;
8 res.send(`Hello ${name}!`);
9});
10
11app.listen(port, () => {
12 console.log(`Server running at http://localhost:${port}`);
13});
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:
1node index.js
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:
1npm install express-session dotenv --save
Please refer to the express-session documentation on GitHub for further instructions on how to set up and establish a secure session, such as setting the cookie’s secure
flag to true
so that it is transmitted only over HTTPS connections. Other security controls should also be addressed with regard to session management, such as regenerating the session identifier on sensitive operations such as login, password change, etc.
Next, create a .env
file in your project’s top-level directory, and add the following code to it:
1SESSION_SECRET=`<your_secret>`
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):
1require("dotenv").config();
2
3const session = require("express-session");
4
5const secret = process.env.SESSION_SECRET;
6const store = new session.MemoryStore();
7const protect = (req, res, next) => {
8 const { authenticated } = req.session;
9
10 if (!authenticated) {
11 res.sendStatus(401);
12 } else {
13 next();
14 }
15};
16
17app.use(
18 session({
19 secret,
20 resave: false,
21 saveUninitialized: true,
22 store,
23 })
24);
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
:
1app.get("/login", (req, res) => {
2 const { authenticated } = req.session;
3
4 if (!authenticated) {
5 req.session.authenticated = true;
6 res.send("Successfully authenticated");
7 } else {
8 res.send("Already authenticated");
9 }
10});
11
12app.get("/logout", protect, (req, res) => {
13 req.session.destroy(() => {
14 res.send("Successfully logged out");
15 });
16});
17
18app.get("/protected", protect, (req, res) => {
19 const { name = "user" } = req.query;
20 res.send(`Hello ${name}!`);
21});
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 a401 (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 a401
status code.Revisiting
/logout
will also return a401
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:
1npm install express-rate-limit --save
Then, we configure the package. Make sure to insert this code in the index.js
file before any routes you want limited:
1const rateLimit = require("express-rate-limit");
2
3app.use(
4 rateLimit({
5 windowMs: 15 * 60 * 1000, // 15 minutes
6 max: 5, // 5 calls
7 })
8);
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.
1npm install winston express-winston response-time --save
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:
1const winston = require("winston");
2const expressWinston = require("express-winston");
3const responseTime = require("response-time");
4
5app.use(responseTime());
6
7app.use(
8 expressWinston.logger({
9 transports: [new winston.transports.Console()],
10 format: winston.format.json(),
11 statusLevels: true,
12 meta: false,
13 msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
14 expressFormat: true,
15 ignoreRoute() {
16 return false;
17 },
18 })
19);
With this configuration, we get some additional output. Now, when starting the server and going to /
, the console should look as follows:
1Server running at http://localhost:3000
2{"level":"info","message":"GET / 200 8ms","meta":{}}
3{"level":"warn","message":"GET /favicon.ico 404 3ms","meta":{}}
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:
1npm install cors helmet --save
We can integrate them like this:
1const cors = require("cors");
2const helmet = require("helmet");
3
4app.use(cors());
5app.use(helmet());
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:
1npm install http-proxy-middleware --save
Now, we’ll add it, along with a new endpoint:
1const { createProxyMiddleware } = require("http-proxy-middleware");
2
3app.use(
4 "/search",
5 createProxyMiddleware({
6 target: "http://api.duckduckgo.com/",
7 changeOrigin: true,
8 pathRewrite: {
9 [`^/search`]: "",
10 },
11 })
12);
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:
1require("dotenv").config();
2
3exports.serverPort = 3000;
4exports.sessionSecret = process.env.SESSION_SECRET;
5exports.rate = {
6 windowMs: 5 * 60 * 1000,
7 max: 100,
8};
9exports.proxies = {
10 "/search": {
11 protected: true,
12 target: "http://api.duckduckgo.com/",
13 changeOrigin: true,
14 pathRewrite: {
15 [`^/search`]: "",
16 },
17 },
18};
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:
1const secret = config.sessionSecret;
2...
3const port = config.serverPort;
4...
5app.use(rateLimit(config.rate));
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
:
1require("dotenv").config();
2...
3app.use(
4 "/search",
5 createProxyMiddleware({
6 target: "http://api.duckduckgo.com/",
7 changeOrigin: true,
8 pathRewrite: {
9 [`^/search`]: "",
10 },
11 })
12);
13...
14app.get("/", (req, res) => {
15 const { name = "user" } = req.query;
16 res.send(`Hello ${name}!`);
17});
18...
19app.get("/protected", protect, (req, res) => {
20 const { name = "user" } = req.query;
21 res.send(`Hello ${name}!`);
22});
Final app
Let’s walk through the contents of our final index.js
file, which has a couple small additions:
1// import all the required packages
2const cors = require("cors");
3const express = require("express");
4const session = require("express-session");
5const rateLimit = require("express-rate-limit");
6const expressWinston = require("express-winston");
7const helmet = require("helmet");
8const { createProxyMiddleware } = require("http-proxy-middleware");
9const responseTime = require("response-time");
10const winston = require("winston");
11const config = require("./config");
12
13// configure the application
14const app = express();
15const port = config.serverPort;
16const secret = config.sessionSecret;
17const store = new session.MemoryStore();
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:
1const alwaysAllow = (_1, _2, next) => {
2 next();
3};
4const protect = (req, res, next) => {
5 const { authenticated } = req.session;
6
7 if (!authenticated) {
8 res.sendStatus(401);
9 } else {
10 next();
11 }
12};
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:
1app.disable("x-powered-by");
2
3app.use(helmet());
4
5app.use(responseTime());
6
7app.use(
8 expressWinston.logger({
9 transports: [new winston.transports.Console()],
10 format: winston.format.json(),
11 statusLevels: true,
12 meta: false,
13 level: "debug",
14 msg: "HTTP {{req.method}} {{req.url}} {{res.statusCode}} {{res.responseTime}}ms",
15 expressFormat: true,
16 ignoreRoute() {
17 return false;
18 },
19 })
20);
21
22app.use(cors());
23
24app.use(rateLimit(config.rate));
25
26app.use(
27 session({
28 secret,
29 resave: false,
30 saveUninitialized: true,
31 store,
32 })
33);
34
35app.get("/login", (req, res) => {
36 const { authenticated } = req.session;
37
38 if (!authenticated) {
39 req.session.authenticated = true;
40 res.send("Successfully authenticated");
41 } else {
42 res.send("Already authenticated");
43 }
44});
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:
1Object.keys(config.proxies).forEach((path) => {
2 const { protected, ...options } = config.proxies[path];
3 const check = protected ? protect : alwaysAllow;
4 app.use(path, check, createProxyMiddleware(options));
5});
6
7app.get("/logout", protect, (req, res) => {
8 req.session.destroy(() => {
9 res.send("Successfully logged out");
10 });
11});
12
13app.listen(port, () => {
14 console.log(`Server running at http://localhost:${port}`);
15});
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.
1const { readFileSync } = require('fs');
2const { createServer } = require('https');
3
4// assumes that the key and certificate are stored in a "cert" directory
5const credentials = {
6 key: readFileSync('cert/server.key', 'utf8'),
7 cert: readFileSync('cert/server.crt', 'utf8'),
8};
9
10// here we use the express "app" to attach to the created server
11const httpsServer = createServer(credentials, app);
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.
1// use standard HTTPS port
2httpsServer.listen(443);
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.