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
Authorizationheader - It verifies the token signature using the
JWT_SECRETenvironment variable - If valid, the decoded user payload is attached to
req.userfor downstream use - Invalid or missing tokens result in
401 Unauthorizedor403 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 Path | Backend Service |
|---|---|
/api/users | USER_SERVICE_URL |
/api/orders | ORDER_SERVICE_URL |
Security Headers with Helmet
The gateway uses helmet to set various HTTP security headers automatically, including:
X-Content-Type-OptionsX-Frame-OptionsX-XSS-ProtectionStrict-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 windowDeveloper Note: Never commit your
.envfile to version control. Add it to.gitignoreand 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:
- Middleware order matters—helmet and CORS run first to apply security headers
- Authentication is conditional: health check bypasses JWT verification
- 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
logsdirectory 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:
- Reads route definitions from
config/routes.js - Creates a proxy middleware for each route
changeOrigin: trueensures theHostheader matches the target servicepathRewritestrips the gateway prefix before forwarding (e.g.,/api/users→/)
Developer Note: For websockets or sticky sessions, you may need additional configuration like
ws: trueor configuring arouterfunction 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:4003Error 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 testTest Coverage
The test suite (tests/gateway_test.js) covers:
-
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'); }); -
JWT Protection
test('Protected route denies request without JWT', async () => { const res = await request(app).get('/api/users'); expect([401, 403]).toContain(res.statusCode); }); -
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); }); -
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
.envfile as the application. For CI/CD pipelines, consider creating a separate.env.testfile to avoid conflicts.
Deployment Considerations
Production Checklist
-
Environment Variables
- Use strong, random values for
JWT_SECRET - Enable rate limiting in production
- Configure production domain(s) in
ALLOWED_ORIGINS
- Use strong, random values for
-
Logging
- Consider adding console transport for development debugging
- Integrate with log aggregation services in production
-
HTTPS
- Always run behind a reverse proxy (Nginx, AWS ALB) with TLS termination
- Enable HSTS headers via Helmet
-
Scaling
- Use Redis for rate limiting in multi-instance deployments
- Consider sticky sessions for WebSocket connections
-
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 adapisConclusion
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
- API Gateway Security Best Practices
- Tyk Open Source API Gateway
- Express.js Documentation
- JSON Web Tokens (JWT)
- Helmet.js
- Express Rate Limit