NodeJSAPI Gateways

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

Text
                          ┌─────────────────────────────────────────┐
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)
The gateway is the only service with a public IP — all microservices run on a private network and are only accessible through it
The gateway enforces the **perimeter**. Internal services don't need their own TLS certificates, rate limiters, or auth logic — they trust that anything arriving from inside the private network has already been validated by the gateway. This dramatically simplifies each service: a users service just handles user logic; it doesn't implement JWT verification, IP rate limiting, or CORS headers. The gateway does all of that once, uniformly, in one place. The downside of this centralization is that the gateway is a **single point of failure** — it must be deployed with redundancy, health checks, and auto-scaling, and any gateway bug or outage affects all services. Treat the gateway with the same operational care you'd give a production database.
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

Bash
npm install express http-proxy-middleware express-rate-limit jsonwebtoken

TS
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'))
Never forward the original Authorization header to upstream services — strip it at the gateway and pass identity via trusted internal headers that only your private network can set
If you proxy the raw `Authorization: Bearer <token>` header to upstream services, any service can then pass that token on to a third service, or it could be logged and leaked. Instead, the gateway **verifies** the token, **extracts** the claims (user ID, role), injects them as internal headers (`X-User-Id`, `X-User-Role`), and strips the original `Authorization` header. Upstream services trust these internal headers without re-verifying the JWT — they just read `req.headers['x-user-id']`. This only works safely if those headers **cannot be set by external clients** — your gateway must strip `X-User-Id` and `X-User-Role` from any incoming request before appending the verified values.
Stripping and overwriting untrusted headers

TS
// 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)

TS
// 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' })
  }
})
The BFF (Backend for Frontend) pattern creates a gateway tailored to a specific client — mobile gets a mobile-optimized aggregation, web gets a web-optimized one
A **Backend for Frontend** is a specialized gateway layer that aggregates and transforms data for a specific client type. Instead of a generic proxy, the mobile BFF fetches exactly the fields the mobile app needs, combines data from multiple services, and returns a lean response optimized for mobile bandwidth. The web BFF might return richer data. This pattern avoids over-fetching (GraphQL solves a similar problem), reduces round-trips (one BFF call vs. three service calls from the client), and lets each client evolve its data contract independently. It's also where client-specific auth flows (device tokens, push notification tokens) live without polluting the core services.
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

Don't put business logic in a gateway — routing, auth, rate limiting, and logging belong there; domain logic belongs in services
Gateways drift toward complexity: teams add field transformation, response enrichment, data validation, and eventually business rules. Once business logic lives in the gateway, it becomes coupled to every service's schema and must be deployed with every feature. The gateway's job is **infrastructure concerns** — routing, security, observability — not domain concerns. If you find yourself putting if-statements about orders or users in your gateway, extract that to a BFF service (which can have business logic) while keeping the gateway itself thin. This separation keeps the gateway stable, testable as pure infrastructure, and upgradeable without touching service logic.
Adding correlation IDs for distributed tracing

TS
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')
Next
Dive into the event-loop internals that underpin all of this: [Event Loop (Advanced)](/nodejs/event-loop-advanced).