Episode 6 — Scaling Reliability Microservices Web3 / 6.2 — Building and Orchestrating Microservices
6.2.b — API Gateway Pattern
In one sentence: An API gateway is a single entry point for all client traffic that handles routing, authentication, rate limiting, and logging — so individual microservices never deal with cross-cutting concerns directly.
Navigation: <- 6.2.a Independent Services | 6.2.c — Retry, Timeout & Circuit Breaker ->
1. What Is an API Gateway?
An API gateway sits between clients and your microservices. Every request goes through the gateway first.
WITHOUT Gateway: WITH Gateway:
Client Client
├── GET /users → :4001 │
├── POST /orders → :4002 ▼
└── GET /notifs → :4003 ┌──────────┐
│ Gateway │ :3000
Client must know every │ ──────── │
service URL and port │ Auth │
No centralized auth │ Route │
No rate limiting │ Limit │
No unified logging │ Log │
└─────┬────┘
┌────┼────┐
▼ ▼ ▼
:4001 :4002 :4003
2. What the Gateway Does
| Responsibility | Description |
|---|---|
| Routing | Maps public URLs to internal service URLs (/api/users -> user-service:4001/users) |
| Authentication | Validates JWT tokens, API keys, or session cookies before forwarding |
| Rate limiting | Prevents abuse by limiting requests per IP, user, or API key |
| Request logging | Logs every incoming request with timing, status, and correlation IDs |
| Load balancing | Distributes traffic across service instances |
| Response caching | Caches frequently requested data to reduce service load |
| Header manipulation | Adds internal headers (e.g., X-User-Id, X-Request-Id) |
| Protocol translation | Accepts REST from clients, forwards as gRPC to services (if needed) |
| SSL termination | Handles HTTPS at the edge; internal traffic can be plain HTTP |
3. Building a Gateway with Express + http-proxy-middleware
3.1 Project Setup
mkdir api-gateway && cd api-gateway
npm init -y
npm install express http-proxy-middleware express-rate-limit jsonwebtoken uuid morgan
3.2 Full Gateway Implementation
// api-gateway/src/index.js
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const { v4: uuidv4 } = require('uuid');
const morgan = require('morgan');
const app = express();
const PORT = process.env.PORT || 3000;
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
// ─── 1. Request ID middleware ───────────────────────────────────────
// Attach a unique ID to every request for distributed tracing
app.use((req, res, next) => {
req.requestId = req.headers['x-request-id'] || uuidv4();
res.setHeader('X-Request-Id', req.requestId);
next();
});
// ─── 2. Logging ─────────────────────────────────────────────────────
morgan.token('request-id', (req) => req.requestId);
app.use(morgan(':method :url :status :response-time ms - :request-id'));
// ─── 3. Rate limiting ──────────────────────────────────────────────
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true,
legacyHeaders: false,
message: {
error: 'Too many requests, please try again later',
retryAfter: '15 minutes',
},
});
app.use(limiter);
// ─── 4. Health check (gateway itself) ──────────────────────────────
app.get('/health', (req, res) => {
res.json({
service: 'api-gateway',
status: 'healthy',
timestamp: new Date().toISOString(),
});
});
// ─── 5. Authentication middleware ───────────────────────────────────
function authenticate(req, res, next) {
// Skip auth for public routes
const publicPaths = ['/api/auth/login', '/api/auth/register', '/health'];
if (publicPaths.some((path) => req.path.startsWith(path))) {
return next();
}
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing or invalid authorization header' });
}
const token = authHeader.split(' ')[1];
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Inject user info as internal headers for downstream services
req.headers['x-user-id'] = decoded.userId;
req.headers['x-user-role'] = decoded.role;
req.headers['x-request-id'] = req.requestId;
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
}
app.use(authenticate);
// ─── 6. Service routing configuration ──────────────────────────────
const services = {
'/api/users': {
target: process.env.USER_SERVICE_URL || 'http://localhost:4001',
pathRewrite: { '^/api/users': '/users' },
},
'/api/orders': {
target: process.env.ORDER_SERVICE_URL || 'http://localhost:4002',
pathRewrite: { '^/api/orders': '/orders' },
},
'/api/notifications': {
target: process.env.NOTIF_SERVICE_URL || 'http://localhost:4003',
pathRewrite: { '^/api/notifications': '/notifications' },
},
};
// ─── 7. Create proxy routes ─────────────────────────────────────────
Object.entries(services).forEach(([path, config]) => {
app.use(
path,
createProxyMiddleware({
target: config.target,
changeOrigin: true,
pathRewrite: config.pathRewrite,
timeout: 5000, // 5-second timeout per request
proxyTimeout: 5000,
// Forward custom headers to services
onProxyReq: (proxyReq, req) => {
proxyReq.setHeader('X-Request-Id', req.requestId);
proxyReq.setHeader('X-Forwarded-For', req.ip);
if (req.headers['x-user-id']) {
proxyReq.setHeader('X-User-Id', req.headers['x-user-id']);
}
console.log(`[gateway] Proxying ${req.method} ${req.originalUrl} -> ${config.target}`);
},
// Handle proxy errors
onError: (err, req, res) => {
console.error(`[gateway] Proxy error: ${err.message}`);
res.status(503).json({
error: 'Service unavailable',
service: path,
requestId: req.requestId,
});
},
})
);
});
// ─── 8. 404 for unmatched routes ────────────────────────────────────
app.use((req, res) => {
res.status(404).json({
error: 'Route not found',
path: req.originalUrl,
requestId: req.requestId,
});
});
// ─── 9. Start ───────────────────────────────────────────────────────
app.listen(PORT, () => {
console.log(`[api-gateway] running on port ${PORT}`);
console.log('[api-gateway] Routes:');
Object.entries(services).forEach(([path, config]) => {
console.log(` ${path} -> ${config.target}`);
});
});
4. Client-to-Service Request Flow
Here is exactly what happens when a client sends GET /api/users/1:
Step 1: Client sends request
GET /api/users/1
Authorization: Bearer eyJhbGci...
│
Step 2: Gateway assigns Request ID
X-Request-Id: 550e8400-e29b-41d4-a716-446655440000
│
Step 3: Rate limiter checks (100 req / 15 min)
✓ Under limit → continue
│
Step 4: Auth middleware validates JWT
✓ Valid token → extract userId=42, role=admin
Set headers: X-User-Id: 42, X-User-Role: admin
│
Step 5: Router matches /api/users → user-service:4001
Path rewrite: /api/users/1 → /users/1
│
Step 6: Proxy forwards to http://user-service:4001/users/1
Adds: X-Request-Id, X-Forwarded-For, X-User-Id
│
Step 7: User service processes request, returns response
200 OK { "data": { "id": "1", "name": "Alice" } }
│
Step 8: Gateway forwards response to client
Adds: X-Request-Id header
5. Authentication at the Gateway Level
Why authenticate at the gateway?
WITHOUT gateway auth: WITH gateway auth:
Client → User Service (auth check) Client → Gateway (auth check) → User Service
Client → Order Service (auth check) Client → Gateway (auth check) → Order Service
Client → Notif Service (auth check) Client → Gateway (auth check) → Notif Service
Each service duplicates auth logic Auth logic lives in ONE place
Token validation N times Token validated ONCE
Login endpoint (gateway handles token creation)
// api-gateway/src/auth-routes.js
const express = require('express');
const jwt = require('jsonwebtoken');
const axios = require('axios');
const router = express.Router();
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const USER_SERVICE_URL = process.env.USER_SERVICE_URL || 'http://localhost:4001';
// POST /api/auth/login
router.post('/login', async (req, res) => {
const { email, password } = req.body;
try {
// Call user service to validate credentials
const response = await axios.post(`${USER_SERVICE_URL}/users/validate`, {
email,
password,
});
const user = response.data.data;
// Generate JWT
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '24h' }
);
res.json({
token,
expiresIn: '24h',
user: { id: user.id, email: user.email, role: user.role },
});
} catch (err) {
res.status(401).json({ error: 'Invalid credentials' });
}
});
module.exports = router;
Services trust gateway headers
Inside each microservice, you do not re-validate the JWT. You trust the headers the gateway injects:
// Inside order-service — trusting gateway headers
app.post('/orders', (req, res) => {
const userId = req.headers['x-user-id']; // Set by gateway
const userRole = req.headers['x-user-role']; // Set by gateway
const requestId = req.headers['x-request-id'];
if (!userId) {
// This should never happen if gateway is configured correctly
return res.status(401).json({ error: 'Missing user context' });
}
// Proceed with order creation using the trusted userId
console.log(`[order-service] Request ${requestId} - User ${userId} creating order`);
});
Security note: Internal services should only be reachable from the gateway, not from the public internet. Docker networks and Kubernetes NetworkPolicies enforce this.
6. Request Forwarding and Header Manipulation
Common headers the gateway adds
| Header | Purpose |
|---|---|
X-Request-Id | Unique ID for distributed tracing across services |
X-User-Id | Authenticated user's ID (from JWT) |
X-User-Role | User's role for authorization decisions |
X-Forwarded-For | Client's real IP address |
X-Forwarded-Proto | Original protocol (http/https) |
X-Gateway-Timestamp | When the gateway received the request |
Stripping sensitive headers
// Remove headers that clients should not set
onProxyReq: (proxyReq, req) => {
// Clients cannot impersonate users by setting these headers
proxyReq.removeHeader('X-User-Id');
proxyReq.removeHeader('X-User-Role');
// Now set them from the validated JWT
if (req.headers['x-user-id']) {
proxyReq.setHeader('X-User-Id', req.headers['x-user-id']);
}
}
7. Gateway vs Direct Service-to-Service Calls
| Scenario | Use Gateway | Use Direct Call |
|---|---|---|
| Client (browser/mobile) to service | Yes | No |
| Service A calls Service B | No | Yes |
| External partner API access | Yes | No |
| Internal health check monitoring | No | Yes |
| Event-driven async communication | No | Use message queue |
Key rule: The gateway is for external traffic. Internal service-to-service communication should be direct (or via message queue), not routed through the gateway.
CORRECT: WRONG:
Client → Gateway → Service A Client → Gateway → Service A
Service A ──→ Service B ↑
Service A → Gateway → Service B
(unnecessary hop, added latency)
8. Popular API Gateway Solutions
| Gateway | Type | Best For |
|---|---|---|
| Express + http-proxy-middleware | Custom, code-based | Learning, small projects, full control |
| Kong | Open source, plugin-based | Production, rich plugin ecosystem |
| AWS API Gateway | Managed service | Serverless (Lambda), AWS-native projects |
| Nginx | Reverse proxy | High-performance routing, SSL termination |
| Traefik | Cloud-native, auto-discovery | Docker/Kubernetes, auto-configuration |
| Express Gateway | Express-based, declarative config | Node.js teams wanting config-driven gateway |
| Envoy | Service mesh proxy | High-performance, gRPC, observability |
When to move beyond a custom Express gateway
Custom Express gateway is fine when:
- < 10 services
- < 1,000 requests/second
- Small team that understands the code
Consider Kong / AWS API Gateway when:
- Need plugin marketplace (OAuth2, OIDC, CORS, transforms)
- > 10,000 requests/second
- Multiple teams managing their own routes
- Need API versioning, developer portals, analytics
9. Docker Compose with Gateway
# docker-compose.yml (complete)
version: '3.8'
services:
api-gateway:
build: ./api-gateway
ports:
- "3000:3000" # Only the gateway is exposed externally
environment:
- PORT=3000
- JWT_SECRET=super-secret-key
- USER_SERVICE_URL=http://user-service:4001
- ORDER_SERVICE_URL=http://order-service:4002
- NOTIF_SERVICE_URL=http://notification-service:4003
depends_on:
- user-service
- order-service
- notification-service
user-service:
build: ./services/user-service
# No "ports:" — NOT exposed externally, only reachable via gateway
expose:
- "4001"
environment:
- PORT=4001
order-service:
build: ./services/order-service
expose:
- "4002"
environment:
- PORT=4002
- USER_SERVICE_URL=http://user-service:4001
notification-service:
build: ./services/notification-service
expose:
- "4003"
environment:
- PORT=4003
Notice that individual services use expose (internal only), not ports (external). Only the gateway has ports: - "3000:3000".
10. Key Takeaways
- The API gateway is the single front door — all external traffic enters through one endpoint.
- Authenticate once at the gateway — downstream services trust gateway-injected headers.
- Rate limiting, logging, and CORS belong in the gateway, not duplicated across services.
- Internal service-to-service calls bypass the gateway — direct communication or message queues.
- Start with a custom Express gateway, move to Kong or AWS API Gateway when complexity demands it.
- In Docker, only expose the gateway port — services communicate on the internal network.
Explain-It Challenge
- A junior developer adds JWT validation to every microservice instead of the gateway. What problems will this cause? How do you fix it?
- The gateway is a single point of failure. How do you make it highly available?
- Your API has 15 services. Managing route configuration in code is becoming painful. What solutions exist?
Navigation: <- 6.2.a Independent Services | 6.2.c — Retry, Timeout & Circuit Breaker ->