A Secure API Gateway for Modern Microservices

Introduction

In the world of microservices architecture, an API gateway serves as the single entry point for all client requests. It handles cross-cutting concerns like authentication, rate limiting, logging, and request routing—allowing your backend services to focus purely on business logic.

adapis is a secure, lightweight API gateway built with Node.js and Express. Inspired by the Adapis primate—known for adaptability, threat detection, and efficient balance—adapis embodies these traits to provide a robust, flexible bridge for your APIs.

In this comprehensive guide, we’ll explore every feature, dig into the implementation details, and walk through practical code examples.


Features

JWT Authentication

adapis secures all proxied endpoints using JSON Web Tokens (JWT). Every request to /api/users or /api/orders must include a valid JWT in the Authorization header.

How it works:

  • The gateway extracts the Bearer token from the Authorization header
  • It verifies the token signature using the JWT_SECRET environment variable
  • If valid, the decoded user payload is attached to req.user for downstream use
  • Invalid or missing tokens result in 401 Unauthorized or 403 Forbidden

Rate Limiting

To prevent abuse and protect your backend services from traffic spikes, adapis implements rate limiting using express-rate-limit.

Default configuration:

  • Window: 60 seconds (configurable via RATE_LIMIT_WINDOW)
  • Max requests: 100 per window (configurable via RATE_LIMIT_MAX)

When a client exceeds the limit, they receive a 429 Too Many Requests response.

Developer Note: In production, you might want to use a Redis-backed store for rate limiting across multiple gateway instances. The default in-memory store works well for single-instance deployments.

CORS Protection

Cross-Origin Resource Sharing (CORS) is configured to allow requests only from approved origins. This prevents malicious sites from making unauthorized requests on behalf of your users.

Implementation:

  • Origins are defined in ALLOWED_ORIGINS (comma-separated)
  • Non-whitelisted origins are blocked with a CORS policy violation error

Request Logging

Every request is logged using Winston, a versatile logging library for Node.js. Logs include:

  • HTTP method
  • Requested URL
  • Client IP address
  • Timestamp

Logs are written to logs/access.log.

Developer Note: For production, consider integrating with external log aggregation services like Datadog, Splunk, or ELK stack. Winston supports multiple transports for this purpose.

Proxy Routing

adapis uses http-proxy-middleware to forward client requests to appropriate backend services. The routing configuration is centralized in config/routes.js, making it easy to add new service endpoints.

Current routes:

Gateway PathBackend Service
/api/usersUSER_SERVICE_URL
/api/ordersORDER_SERVICE_URL

Security Headers with Helmet

The gateway uses helmet to set various HTTP security headers automatically, including:

  • X-Content-Type-Options
  • X-Frame-Options
  • X-XSS-Protection
  • Strict-Transport-Security

Configuration

Create a .env file in the root directory with the following variables:

# Server Configuration
PORT=3000
 
# JWT Authentication
JWT_SECRET=your_super_secure_jwt_secret_here
 
# Backend Service URLs
USER_SERVICE_URL=http://localhost:4001
ORDER_SERVICE_URL=http://localhost:4002
 
# Security
ALLOWED_ORIGINS=https://yourfrontend.com,https://admin.yourfrontend.com
 
# Rate Limiting
RATE_LIMIT_WINDOW=60000    # 1 minute in milliseconds
RATE_LIMIT_MAX=100         # requests per window

Developer Note: Never commit your .env file to version control. Add it to .gitignore and use environment-specific secrets management in production (e.g., AWS Secrets Manager, HashiCorp Vault).


Code Deep Dive

Entry Point: index.js

require('dotenv').config();
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const limiter = require('./config/rateLimit');
const corsOptions = require('./config/corsOptions');
const authenticateToken = require('./middleware/authenticate');
const requestLogger = require('./middleware/logger');
const errorHandler = require('./middleware/errorHandler');
const setupProxy = require('./services/proxyService');
const routes = require('./config/routes');
 
const app = express();
 
// Apply security middleware in order
app.use(helmet());           // Security headers
app.use(cors(corsOptions)); // CORS protection
app.use(express.json());     // Parse JSON bodies
app.use(limiter);            // Rate limiting
app.use(requestLogger);      // Request logging
 
// Conditional JWT authentication for proxied routes
app.use((req, res, next) => {
  if (Object.keys(routes).some(route => req.path.startsWith(route))) {
    return authenticateToken(req, res, next);
  }
  next();
});
 
// Setup reverse proxy
setupProxy(app);
 
// Health check endpoint (public, no auth required)
app.get('/healthz', (req, res) => res.json({ status: 'ok', timestamp: Date.now() }));
 
// Global error handler
app.use(errorHandler);
 
const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`adapis secure API gateway running on port ${port}`);
});

Key observations:

  1. Middleware order matters—helmet and CORS run first to apply security headers
  2. Authentication is conditional: health check bypasses JWT verification
  3. The proxy is set up before the error handler to catch proxy errors

JWT Authentication Middleware: middleware/authenticate.js

const jwt = require('jsonwebtoken');
 
function authenticateToken(req, res, next) {
  // Extract Bearer token from Authorization header
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  // No token provided
  if (!token) return res.status(401).json({ error: 'Missing token' });
 
  // Verify token signature
  jwt.verify(token, process.env.JWT_SECRET, (err, user) => {
    if (err) return res.status(403).json({ error: 'Invalid or expired token' });
    
    // Attach decoded user to request for downstream use
    req.user = user;
    next();
  });
}
 
module.exports = authenticateToken;

Developer Note: This middleware uses the callback-based jwt.verify() API. For better error handling and cleaner code, consider using the Promise-based API with async/await:

const jwt = require('jsonwebtoken');
 
async function authenticateToken(req, res, next) {
  const authHeader = req.headers['authorization'];
  const token = authHeader && authHeader.split(' ')[1];
  
  if (!token) return res.status(401).json({ error: 'Missing token' });
 
  try {
    const user = await jwt.verify(token, process.env.JWT_SECRET);
    req.user = user;
    next();
  } catch (err) {
    return res.status(403).json({ error: 'Invalid or expired token' });
  }
}

Rate Limiting: config/rateLimit.js

const rateLimit = require('express-rate-limit');
 
const limiter = rateLimit({
  // Time window in milliseconds
  windowMs: parseInt(process.env.RATE_LIMIT_WINDOW) || 60000,
  
  // Maximum requests per window
  max: parseInt(process.env.RATE_LIMIT_MAX) || 100,
  
  // Response when limit exceeded
  message: 'Too many requests, please try again later.',
});
 
module.exports = limiter;

Developer Note: The rate limiter is applied globally to all routes. If you need different limits for different endpoints, you can create multiple limiters and apply them selectively.

CORS Configuration: config/corsOptions.js

const allowedOrigins = process.env.ALLOWED_ORIGINS?.split(',') || [];
 
const corsOptions = {
  // Custom origin validator
  origin: (origin, callback) => {
    // Allow requests with no origin (like mobile apps or Postman)
    // OR allow if origin is in the whitelist
    if (!origin || allowedOrigins.indexOf(origin) !== -1) {
      callback(null, true);
    } else {
      callback(new Error('CORS policy violation'), false);
    }
  },
  optionsSuccessStatus: 200 // Some legacy browsers choke on 204
};
 
module.exports = corsOptions;

Developer Note: This implementation blocks non-whitelisted origins but still allows same-origin requests. Adjust if you need stricter policies.

Request Logging: middleware/logger.js

const winston = require('winston');
 
// Configure Winston logger
const logger = winston.createLogger({
  level: 'info',
  format: winston.format.combine(
    winston.format.timestamp(),
    winston.format.printf(({ timestamp, level, message }) => {
      return `[${timestamp}] ${level}: ${message}`;
    })
  ),
  transports: [
    new winston.transports.File({ filename: 'logs/access.log' })
  ]
});
 
// Middleware to log each request
function requestLogger(req, res, next) {
  logger.info(`${req.method} ${req.originalUrl} | IP: ${req.ip}`);
  next();
}
 
module.exports = requestLogger;

Developer Note: Remember to create the logs directory before running the application, or configure Winston to create it automatically:

const fs = require('fs');
if (!fs.existsSync('logs')) {
  fs.mkdirSync('logs');
}

Proxy Service: services/proxyService.js

const { createProxyMiddleware } = require('http-proxy-middleware');
const routes = require('../config/routes');
 
function setupProxy(app) {
  // Iterate over all configured routes
  Object.entries(routes).forEach(([path, target]) => {
    app.use(path, createProxyMiddleware({
      target,              // Backend service URL
      changeOrigin: true,  // Change the Host header to match target
      pathRewrite: {       // Remove the gateway path from the forwarded request
        [`^${path}`]: ''
      },
      onError: (err, req, res) => {
        console.error('Proxy error:', err.message);
        res.status(502).json({ error: 'Proxy error encountered.' });
      }
    }));
  });
}
 
module.exports = setupProxy;

How it works:

  1. Reads route definitions from config/routes.js
  2. Creates a proxy middleware for each route
  3. changeOrigin: true ensures the Host header matches the target service
  4. pathRewrite strips the gateway prefix before forwarding (e.g., /api/users/)

Developer Note: For websockets or sticky sessions, you may need additional configuration like ws: true or configuring a router function for dynamic target resolution.

Route Configuration: config/routes.js

module.exports = {
  // Add new routes here
  '/api/users': process.env.USER_SERVICE_URL,
  '/api/orders': process.env.ORDER_SERVICE_URL,
};

Developer Note: To add a new service, simply add a new key-value pair:

'/api/products': process.env.PRODUCT_SERVICE_URL,

And update your .env:

PRODUCT_SERVICE_URL=http://localhost:4003

Error Handler: middleware/errorHandler.js

function errorHandler(err, req, res, next) {
  console.error('Error:', err.stack || err);
  res.status(500).json({ error: 'Internal Server Error' });
}
 
module.exports = errorHandler;

Developer Note: For production, consider logging errors to an external service and returning a generic error message to avoid leaking implementation details.


Testing

adapis includes a comprehensive test suite using Jest and Supertest.

Running Tests

npm test

Test Coverage

The test suite (tests/gateway_test.js) covers:

  1. Health Check Endpoint

    test('Health check endpoint returns status ok', async () => {
      const res = await request(app).get('/healthz');
      expect(res.statusCode).toBe(200);
      expect(res.body.status).toBe('ok');
    });
  2. JWT Protection

    test('Protected route denies request without JWT', async () => {
      const res = await request(app).get('/api/users');
      expect([401, 403]).toContain(res.statusCode);
    });
  3. Rate Limiting

    test('Rate limiting returns error after exceeding limit', async () => {
      for (let i = 0; i <= process.env.RATE_LIMIT_MAX; i++) {
        await request(app).get('/healthz');
      }
      const res = await request(app).get('/healthz');
      expect(res.statusCode).toBe(429);
    });
  4. CORS Whitelisting

    test('CORS allows whitelisted origin', async () => {
      const origin = process.env.ALLOWED_ORIGINS.split(',')[0];
      const res = await request(app)
        .get('/healthz')
        .set('Origin', origin);
      expect(res.headers['access-control-allow-origin']).toBe(origin);
    });

Developer Note: Tests currently use the same .env file as the application. For CI/CD pipelines, consider creating a separate .env.test file to avoid conflicts.


Deployment Considerations

Production Checklist

  1. Environment Variables

    • Use strong, random values for JWT_SECRET
    • Enable rate limiting in production
    • Configure production domain(s) in ALLOWED_ORIGINS
  2. Logging

    • Consider adding console transport for development debugging
    • Integrate with log aggregation services in production
  3. HTTPS

    • Always run behind a reverse proxy (Nginx, AWS ALB) with TLS termination
    • Enable HSTS headers via Helmet
  4. Scaling

    • Use Redis for rate limiting in multi-instance deployments
    • Consider sticky sessions for WebSocket connections
  5. Monitoring

    • Add health check monitoring (e.g., Prometheus, DataDog)
    • Set up alerts for 5xx errors and rate limit rejections

Docker Support (Optional)

FROM node:20-alpine
 
WORKDIR /app
 
COPY package*.json ./
RUN npm ci --only=production
 
COPY . .
 
EXPOSE 3000
 
CMD ["npm", "start"]
docker build -t adapis .
docker run -p 3000:3000 --env-file .env adapis

Conclusion

adapis provides a solid foundation for securing microservices with minimal setup. Its modular design makes it easy to customize—add new routes, adjust rate limits, or integrate additional authentication providers.

Key takeaways:

  • JWT authentication secures all proxied endpoints
  • Rate limiting protects against abuse
  • CORS ensures only approved origins can access your API
  • Centralized logging aids debugging and compliance
  • Proxy routing simplifies client-side integration

For production deployments, consider extending the error handler, adding request validation, and integrating with your existing infrastructure.


References