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 reduce 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
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 https://localhost:${port}`);
});
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
Now, navigate to?https://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 storeng 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
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:
SESSION_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):
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,
})
);
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}!`);
});
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:
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
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
})
);
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
The integration of these packages is straightforward. Add the following code to?index.js?before any code you want to be 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;
},
})
);
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 https://localhost:3000
{"level":"info","message":"GET / 200 8ms","meta":{}}
{"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?OPTIONS?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?a helmet. We can install both packages using the code below:
npm install cors helmet --save
We can integrate them like this:
const cors = require("cors");
const helmet = require("helmet");
app.use(cors());
app.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:
npm install http-proxy-middleware --save
Now, we’ll add it, along with a new endpoint:
const { createProxyMiddleware } = require("http-proxy-middleware");
app.use(
"/search",
createProxyMiddleware({
target: "https://api.duckduckgo.com/",
changeOrigin: true,
pathRewrite: {
[`^/search`]: "",
},
})
);
You can test this by starting your server and accessing?/searching.q=x&format=json, which returns the results obtained from proxying the request to?https://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: "https://api.duckduckgo.com/",
changeOrigin: true,
pathRewrite: {
[`^/search`]: "",
},
},
};
Here, we created the essential configuration of our API gateway. We set its port, cookie session encryption key, and the different endpoints to the 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?rate-limit?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));
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: "https://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}!`);
});
Final app
Let’s walk through the contents of our final?index.js?file, which has a couple of 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();
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?always allow?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();
}
};
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");
}
});
Next, we iterate over the proxies listed in?config.js, check the value of their?protected?parameter, call either the?protect?or?always allow?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 https://localhost:${port}`);
});
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);
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);
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.
Technical Lead @ Accenture | Ex SHL | Ex Magic EdTech
3 个月Hey Sunil I found something over the internet. Looks like you copied it from here https://snyk.io/blog/how-to-build-secure-api-gateway-node-js/