API Gateway
An API gateway is a single entry point that sits in front of all your microservices and handles cross-cutting concerns that would otherwise be duplicated in every service: authentication, rate limiting, request routing, response aggregation, logging, and TLS termination. Instead of clients knowing the addresses of dozens of services and each service implementing its own auth, they call one gateway that enforces policy uniformly and proxies to the right service. This page covers what an API gateway does, the patterns around it (Backend for Frontend, request aggregation), and when to build a Node.js gateway vs using an off-the-shelf solution.
What an API gateway does
┌─────────────────────────────────────────┐
Client (browser/mobile) │ API Gateway │
│ │ │
│ GET /users/123 │ 1. Authenticate (verify JWT) │
└────────────────►│ 2. Authorize (check role) │
│ 3. Rate limit (100 req/min per IP) │
│ 4. Route → Users Service :3001 │
│ 5. Log request + response time │
│ 6. Return response │
└─────────────────────────────────────────┘
│ │ │
Users :3001 Orders :3002 Billing :3003
(internal — (internal) (internal)
no public
exposure)Cross-cutting concerns
Concern | Where it lives without gateway | With gateway |
|---|---|---|
Authentication | Every service verifies tokens independently | Gateway verifies once, passes claims downstream |
Rate limiting | Each service implements its own limits | One rate limiter at the perimeter |
TLS termination | Each service manages its own cert | Gateway terminates TLS; internal traffic is plain HTTP |
CORS headers | Every service sets CORS policy | Gateway adds CORS headers uniformly |
Request logging | Each service logs differently | Centralized correlation IDs and access logs |
Circuit breaking | Client-side per service | Gateway-side, protecting all upstream clients |
Building a simple Node.js gateway with http-proxy-middleware
npm install express http-proxy-middleware express-rate-limit jsonwebtoken
import express from 'express'
import { createProxyMiddleware } from 'http-proxy-middleware'
import rateLimit from 'express-rate-limit'
import jwt from 'jsonwebtoken'
const app = express()
// 1. Rate limiting — applied before any routing:
app.use(rateLimit({ windowMs: 60_000, max: 100, standardHeaders: true }))
// 2. Authentication middleware — verify JWT and attach claims:
function authenticate(req: express.Request, res: express.Response, next: express.NextFunction) {
const token = req.headers.authorization?.split(' ')[1]
if (!token) return res.status(401).json({ error: 'Missing token' })
try {
const claims = jwt.verify(token, process.env.JWT_SECRET!) as { sub: string; role: string }
// Pass verified identity to upstream services via headers:
req.headers['x-user-id'] = claims.sub
req.headers['x-user-role'] = claims.role
delete req.headers['authorization'] // don't leak the token to upstream
next()
} catch {
res.status(401).json({ error: 'Invalid token' })
}
}
// 3. Route to services — paths map to internal service addresses:
app.use('/users', authenticate, createProxyMiddleware({ target: 'http://users-service:3001', changeOrigin: true }))
app.use('/orders', authenticate, createProxyMiddleware({ target: 'http://orders-service:3002', changeOrigin: true }))
app.use('/billing', authenticate, createProxyMiddleware({ target: 'http://billing-service:3003', changeOrigin: true }))
// Public route — no auth required:
app.use('/health', (req, res) => res.json({ status: 'ok' }))
app.listen(8080, () => console.log('Gateway listening on :8080'))Stripping and overwriting untrusted headers
// Always strip headers that external clients could forge before routing:
app.use((req, _res, next) => {
delete req.headers['x-user-id']
delete req.headers['x-user-role']
delete req.headers['x-internal-secret']
next()
})
// After this, only the authenticate middleware (above) can set x-user-id and x-user-role.Request aggregation (BFF pattern)
// Backend for Frontend: one gateway endpoint fetches from multiple services
// and assembles a single response — eliminates multiple round-trips from the client:
app.get('/dashboard', authenticate, async (req, res) => {
const userId = req.headers['x-user-id'] as string
try {
const [user, orders, billing] = await Promise.all([
fetch(`http://users-service:3001/users/${userId}`).then(r => r.json()),
fetch(`http://orders-service:3002/orders?userId=${userId}`).then(r => r.json()),
fetch(`http://billing-service:3003/balance?userId=${userId}`).then(r => r.json()),
])
res.json({ user, orders, billing })
} catch (err) {
res.status(502).json({ error: 'Upstream service error' })
}
})Off-the-shelf gateway vs custom Node gateway
Approach | Pros | Cons | Choose when |
|---|---|---|---|
Custom Node gateway | Full control, custom aggregation logic, same language as services | You own the reliability, you implement all features | Need complex aggregation, BFF logic, or non-standard auth flows |
Kong | Plugin ecosystem, declarative config, high performance (nginx-based) | Heavy, separate ops burden, plugin model can be rigid | Enterprise scale, many teams, standardized routing and auth |
AWS API Gateway | Fully managed, integrates with Lambda/ALB, built-in throttling | Vendor lock-in, limited aggregation, cost at scale | Already on AWS, serverless architecture |
Traefik / Nginx | Excellent reverse proxy and load balancer, TLS automation | Not designed for aggregation or business logic | Simple routing, TLS termination, no aggregation needed |
Adding correlation IDs for distributed tracing
import { randomUUID } from 'node:crypto'
// Gateway stamps every request with a unique correlation ID:
app.use((req, _res, next) => {
const correlationId = (req.headers['x-correlation-id'] as string) ?? randomUUID()
req.headers['x-correlation-id'] = correlationId
next()
})
// Upstream services read and log the correlation ID so all logs for a request are linkable:
// In users-service: logger.info({ correlationId: req.headers['x-correlation-id'] }, 'Fetching user')