Building API Gateway in Node.js: Part II — Security, Authentication & Authorization
Protecting Your Microservices
Welcome to Part II of our series on “Building API Gateway in Node.js”. In the first part, we covered the basics of what an API Gateway is, its benefits and drawbacks, and implemented the routing component. You can find the previous post below:
In this part, I will investigate the next important aspect of API Gateway—security. Securing an API Gateway is essential to protecting your microservices architecture from unauthorized access, data breaches, and other security threats. In this post, I will cover the implementation of authentication and authorization mechanisms to safeguard our services through the API Gateway.
Overview
Security encompasses many aspects, each playing a crucial role in protecting your API Gateway and the underlying services:
- authentication: verifying the identity of clients attempting to access your application;
- authorization: ensuring authenticated clients have the required permissions to access specific resources or perform certain actions;
- rate limiting and throttling: protecting your services from abuse by limiting the number of requests a client can make. (I’ll cover proper rate-limiting implementation in future parts);
- data encryption: securing data in transit and at rest to protect sensitive information from being intercepted or accessed by unauthorized parties;
- input validation and sanitization: preventing injection attacks and other exploits by validating and sanitizing all incoming data (I’ll cover proper validation and sanitization implementation in future parts).
In this post, I want to cover authentication & authorization aspects. Let’s start with first!
Authentication
Authentication is the process that allows systems to identify their clients. From the beginning, let’s talk about two authentication categories:
- system authentication involves verifying the identity of machines or services communicating with your API Gateway;
- client authentication involves verifying the identity of individual users accessing your application.
Different techniques are used to support both system and client authentication:
- system authentication: when you need to authenticate a system, the client usually has a unique API token that is used to identify the system during requests. Alternatively, a pair of username and password can be used to obtain a token, which is then used to perform subsequent requests;
- client authentication: a common approach is using OAuth, where users authenticate and receive an access token that grants them permission to access specific resources.
One of the most popular formats for tokens is JSON Web Tokens (JWT) — a compact, secure token format that encodes JSON objects. JWTs consist of three parts:
- header: typically consists of two parts: the type of token (JWT) and the signing algorithm being used, such as HMAC SHA256 or RSA;
- payload: contains the claims. Claims are statements about an entity (client) and additional data;
- signature: to create it, you have to take the encoded header, the encoded payload, a secret, and the algorithm specified in the header, and sign that.
All of the JWT parts are base64 encoded and concatenated with periods.
Although anyone can decode a JWT to view its contents, verifying its authenticity requires validating its signature using an encryption algorithm. If you’re using a symmetric encryption algorithm (such as HMAC), both the issuer and the verifier must share the same secret key. If you’re using an asymmetric encryption algorithm (such as RSA or ECDSA), the issuer signs the token with a private key, and the verifier checks the signature with the corresponding public key.
Authorization
The next step is to ensure the client has access to a specific resource or action. Authorization is the process of enforcing permissions and access control. There are several approaches to implementing authorization:
- Role-Based Access Control (RBAC): users are assigned to roles, and each role has specific permissions. For example, an “admin” role might have permission to read and modify resources, while a “user” role might only have read access;
- Attribute-Based Access Control (ABAC): access is granted based on user attributes, resource attributes, and the environment. For example, a user may have access only to their own personal information and not to other users’ information;
- Policy-Based Access Control (PBAC): policies are defined in a policy language to determine access. A good example of this is AWS IAM policies, which use JSON-based documents to specify permissions;
- Scopes: specific sets of permissions associated with a client. For example, a client might have a “read” scope that allows them to access information in read-only mode;
- Access Control Lists (ACLs): define a list of clients or roles and their permissions for a specific resource. For example, an ACL might specify that certain users have read access while others have write access.
Implementation
Let’s create an authorization service that can authenticate and authorize clients. This service will generate JWT tokens, with one of the claims in the payload responsible for identifying client and its permissions.
Here is the proposed configuration for this service:
[
{
"client_name": "Client 1",
"client_id": "d9e723a0-ccc4-49f6-b146-f87ce96197f2",
"client_secret": "MI4AmCDLFdGVOqlvyHy9woHSVyUCRAxh",
"scopes": [
"user:read",
"user:write"
],
"grant_types": [
"client_credentials"
],
"redirect_uris": [],
"signing_alg": "HS256",
"jwks": {
"k": "QjJ2djY5QVo4b2R0bzcwTjFxRU40ejRFNkZwYVpBaFU="
},
"exp": 3600
}
]
Each object in the configuration represents a client description, including:
client_name
: a human-readable name for the client;client_id
: a unique identifier used in the token generation flow and included as part of the JWT claims;client_secret
: a secret used in the authentication flow. Note: Do not store this value under source control! This example includes it only for learning purposes;scopes
: an array of scopes available for the client to request;grant_types
: an array of strings representing the OAuth 2.0 grant types that the client is allowed to use;redirect_uris
: an array of URIs to which the authorization server will redirect the user after granting authorization;signing_alg
: the algorithm used for signing tokens;jwks
: a JSON Web Key Set (JWKS) object containing cryptographic keys used for signing and verifying tokens;k
: The actual key value, encoded in base64 format. Note: Do not store this value under source control! This example includes it only for learning purposes;exp
: The expiration time for tokens is in seconds.
From the outset, I’m designing an evolutionary schema, but initially, I will implement only the server authentication flow using the client_credentials
grant type.
Here is the server code implemented using the Node.js Express framework:
const clients = require('./clients.json');
const express = require('express');
const multer = require('multer');
const jwt = require('jsonwebtoken');
const app = express();
app.use(multer().none());
app.get('/clients', (_, res) => {
res.json(clients);
});
app.post('/oauth2/token', (req, res) => {
if (!req.headers.authorization) {
res.status(401).json({ error: 'invalid_client' });
return;
}
if (!req.body) {
res.status(400).json({ error: 'invalid_request' });
return;
}
if (req.headers.authorization.split(' ')[0] !== 'Basic') {
res.status(401).json({ error: 'invalid_client' });
return;
}
// read creds from authorization header
const [client_id, client_secret] = Buffer.from(
req.headers.authorization.split(' ')[1],
'base64'
)
.toString()
.split(':');
// find client
const client = clients.find((client) => client.client_id === client_id);
// check client secret
if (client?.client_secret !== client_secret) {
res.status(401).json({ error: 'invalid_client' });
return;
}
// check grant type
if (req.body.grant_type !== 'client_credentials') {
res.status(400).json({ error: 'unsupported_grant_type' });
return;
}
if (!client.grant_types.includes(req.body.grant_type)) {
res.status(400).json({ error: 'unauthorized_client' });
return;
}
// check scopes
if (!req.body.scope) {
res.status(400).json({ error: 'invalid_scope' });
return;
}
if (
req.body.scope
.split(' ')
.some((scope) => !client.scopes.includes(scope))
) {
res.status(400).json({ error: 'invalid_scope' });
return;
}
// generate JWT
const {
signing_alg,
jwks: { k },
exp,
} = client;
const now = Math.floor(Date.now() / 1000);
const token = jwt.sign(
{
client_name: client.client_name,
client_id,
scope: req.body.scope.split(' '),
iat: now,
nbf: now,
exp: now + exp,
iss: 'urn:auth',
},
Buffer.from(k, 'base64').toString(),
{
algorithm: signing_alg,
}
);
res.json({ access_token: token, token_type: 'Bearer', expires_in: exp });
});
app.listen(3002, () => {
console.log('Server is running on http://localhost:3002');
});
Requests go through a comprehensive validation pipeline, including:
- authorization header validation: ensures that the authorization header is present and correctly formatted with Basic authentication;
- client credentials verification: extracts and validates the client ID and client secret from the authorization header against the stored client information;
- grant type verification: checks that the grant type is
client_credentials
and that this grant type is permitted for the client; - scope validation: ensures that the requested scopes are valid and allowed for the client.
Once the request passes these validation checks, the server generates a JWT for the client. This token includes claims such as the client ID, client name, requested scopes, and issue and expiration times and is signed using the specified algorithm and key.
To verify tokens, the API Gateway needs access to client information. For this purpose, the authorization service exposes a /clients
API that returns the JSON configuration. It is crucial to ensure that this API is not accessible to unauthorized external parties to maintain security.
Now let’s update the API Gateway configuration:
[
{
"name": "auth-clients-service",
"methods": ["GET"],
"context": ["/clients"],
"target": "http://localhost:3002",
"pathRewrite": {},
"internal": true
},
{
"name": "auth-service",
"methods": ["POST"],
"context": ["/auth"],
"target": "http://localhost:3002",
"pathRewrite": {
"^/auth": "/oauth2"
}
},
{
"name": "user-service-read",
"methods": ["GET"],
"context": ["/users"],
"target": "http://localhost:3001",
"pathRewrite": {},
"security": {
"scope": "user:read"
}
},
{
"name": "user-service-write",
"methods": ["POST", "PUT", "DELETE"],
"context": ["/users"],
"target": "http://localhost:3001",
"pathRewrite": {},
"security": {
"scope": "user:write"
}
}
]
The route configuration has been extended with two optional properties:
internal
: indicates that the route is not available to end users;methods
: allowed HTTP methods for route configuration;security
: an object specifying the security configuration, such as the required scope for accessing the route.
If the route configuration does not include a security
property, it means the route is available to unauthenticated users.
To improve modularity, I’ve refactored the logic of the proxy service into small processors that act as middleware. These processors can validate the request and either pass it to the next processor or stop the execution if the request is invalid (e.g. when the token is expired).
Here is an example of a security processor:
const { getClients } = require('../cache/clients')
const { jwtDecode } = require('jwt-decode')
const jwt = require('jsonwebtoken')
const errors = {
UNATHORIZED: 'UNATHORIZED',
FORBIDDEN: 'FORBIDDEN',
}
class SecurityProcessor {
/**
* Creates a new security handler
* @typedef {import('./types').Route} Route
* @param {Route} route
* @param {import('express').Request} req
*/
constructor(route, req) {
this.__route = route
this.__req = req
this.__clients = getClients()
}
/**
* Processes the request
* @typedef {import('./types').Result} Result
* @returns {Result}
*/
process() {
const { scope } = this.__route.security
const { authorization } = this.__req.headers
// get bearer token
if (!authorization) {
return {
error: errors.UNATHORIZED,
message: 'unauthorized',
result: false,
}
}
const [bearer, token] = authorization.split(' ')
if (bearer !== 'Bearer') {
return {
error: errors.UNATHORIZED,
message: 'unauthorized',
result: false,
}
}
// decode token to get client_id claim
let decoded
try {
decoded = jwtDecode(token)
} catch {
return {
error: errors.UNATHORIZED,
message: 'invalid token',
result: false,
}
}
// get client
const client = this.__clients.find(
(client) => client.client_id === decoded.client_id
)
if (!client) {
return {
error: errors.UNATHORIZED,
message: 'client not found',
result: false,
}
}
// verify token
let claims
try {
const signingKey = Buffer.from(client.jwks.k, 'base64').toString()
claims = jwt.verify(token, signingKey)
} catch {
return {
error: errors.UNATHORIZED,
message: 'invalid token',
result: false,
}
}
// verify scope
if (!claims.scope.includes(scope)) {
return {
error: errors.FORBIDDEN,
message: 'insufficient scope',
result: false,
}
}
const headers = {
'X-Client-Id': client.client_id,
'X-Client-Name': client.client_name,
}
return { result: true, headers }
}
}
module.exports = SecurityProcessor
module.exports.errors = errors
The security processor extends the proxy request by adding client context information in the X-Client-Id
and X-Client-Name
headers.
The proxy code has evolved to this:
const fetch = require('node-fetch')
const ChainProcessor = require('../processors/chain')
const SecurityProcessor = require('../processors/security')
const HeadersProcessor = require('../processors/headers')
const BodyProcessor = require('../processors/body')
const UrlProcessor = require('../processors/url')
const routes = require('../routes/routes.json')
const errors = {
ROUTE_NOT_FOUND: 'ROUTE_NOT_FOUND',
}
const defaultTimeout = parseInt(process.env.HTTP_DEFAULT_TIMEOUT) || 5000
/**
* @typedef {import('node-fetch').Response} Response
* @typedef Result
* @property {string} error
* @property {string} message
* @property {Response} response
*/
/**
* Method proxies the request to the appropriate service
* @typedef {import('express').Request} Request
* @param {Request} req
* @returns {Promise<Result>}
*/
async function handler(req) {
const { path, method } = req
const route = routes.find(
(route) =>
route.context.some((c) => path.startsWith(c)) &&
route.methods.includes(method)
)
if (!route || route?.internal) {
return {
error: errors.ROUTE_NOT_FOUND,
message: 'route not found',
}
}
const chain = new ChainProcessor()
chain.add(new BodyProcessor(req))
chain.add(new UrlProcessor(route, req))
chain.add(new HeadersProcessor(route, req))
if (route.security) {
chain.add(new SecurityProcessor(route, req))
}
const processorResult = chain.process({
authorization: req.headers.authorization,
})
if (!processorResult.result) {
return {
error: processorResult.error,
message: processorResult.message,
}
}
const response = await fetch(processorResult.url, {
method,
headers: processorResult.headers,
body: processorResult.body,
follow: 0,
timeout: route?.timeout || defaultTimeout,
})
return {
response,
}
}
module.exports = handler
module.exports.errors = errors
The other processors' codes you can find below by following the GitHub link.
Let’s try to perform some requests! First, let’s issue the token:
curl --location 'localhost:3000/auth/token' \
--header 'Authorization: ••••••' \
--form 'grant_type="client_credentials"' \
--form 'scope="user:read"'
This call includes basic authorization. The request is forwarded to the authorization service, which returns the following response:
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfbmFtZSI6IkNsaWVudCAxIiwiY2xpZW50X2lkIjoiZDllNzIzYTAtY2NjNC00OWY2LWIxNDYtZjg3Y2U5NjE5N2YyIiwic2NvcGUiOlsidXNlcjpyZWFkIl0sImlhdCI6MTcyMjk1NjY3NCwibmJmIjoxNzIyOTU2Njc0LCJleHAiOjE3MjI5NjAyNzQsImlzcyI6InVybjphdXRoIn0.zufXsmajO_D6RHnykyOaZyblLRaJ2GTGktr2lUh6_xc",
"token_type": "Bearer",
"expires_in": 3600
}
If you try to decode JWT, you will see the following information:
Let’s do the next request to service that is secured with API Gateway, but without a token:
curl --location 'localhost:3000/users/1'
You will see the next error:
{
"error": "UNATHORIZED",
"message": "unauthorized"
}
Now let’s try to create user with the token generated above:
curl --location 'localhost:3000/users' \
--header 'Content-Type: application/json' \
--header 'Authorization: ••••••' \
--data '{
"id": "1",
"name": "Dima"
}'
You should receive the next error because a client requested just user:read
scope:
{
"error": "FORBIDDEN",
"message": "insufficient scope"
}
If you regenerate the token with the user:write
scope and try the POST request again, you should receive a successful result:
{
"id": "1",
"name": "Dima"
}
However, having only the user:write
scope will not grant you permission to read user information. To access multiple permissions, you can generate a single token that includes all the necessary scopes.
Application Code
You can find the application code in the next repository:
Don’t forget to start the repository if you like it!
Support Me
If you found joy or value in my article and would like to show your appreciation, you can now donate with ‘Buy Me A Coffee’! Your support will go a long way in helping me continue to share stories, insights, and content that resonates with you.
Conclusions
In this part of our series on building an API Gateway with Node.js, we focused on implementing authentication and authorization to protect underlying services from unauthorized access. Authentication and authorization are crucial aspects of securing your API Gateway, but they are just the beginning.
In the next part, I’ll explore rate-limiting to protect your services from abuse and ensure fair usage. Stay tuned for more insights and practical examples!