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

ResponsibilityDescription
RoutingMaps public URLs to internal service URLs (/api/users -> user-service:4001/users)
AuthenticationValidates JWT tokens, API keys, or session cookies before forwarding
Rate limitingPrevents abuse by limiting requests per IP, user, or API key
Request loggingLogs every incoming request with timing, status, and correlation IDs
Load balancingDistributes traffic across service instances
Response cachingCaches frequently requested data to reduce service load
Header manipulationAdds internal headers (e.g., X-User-Id, X-Request-Id)
Protocol translationAccepts REST from clients, forwards as gRPC to services (if needed)
SSL terminationHandles 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

HeaderPurpose
X-Request-IdUnique ID for distributed tracing across services
X-User-IdAuthenticated user's ID (from JWT)
X-User-RoleUser's role for authorization decisions
X-Forwarded-ForClient's real IP address
X-Forwarded-ProtoOriginal protocol (http/https)
X-Gateway-TimestampWhen 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

ScenarioUse GatewayUse Direct Call
Client (browser/mobile) to serviceYesNo
Service A calls Service BNoYes
External partner API accessYesNo
Internal health check monitoringNoYes
Event-driven async communicationNoUse 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

GatewayTypeBest For
Express + http-proxy-middlewareCustom, code-basedLearning, small projects, full control
KongOpen source, plugin-basedProduction, rich plugin ecosystem
AWS API GatewayManaged serviceServerless (Lambda), AWS-native projects
NginxReverse proxyHigh-performance routing, SSL termination
TraefikCloud-native, auto-discoveryDocker/Kubernetes, auto-configuration
Express GatewayExpress-based, declarative configNode.js teams wanting config-driven gateway
EnvoyService mesh proxyHigh-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

  1. The API gateway is the single front door — all external traffic enters through one endpoint.
  2. Authenticate once at the gateway — downstream services trust gateway-injected headers.
  3. Rate limiting, logging, and CORS belong in the gateway, not duplicated across services.
  4. Internal service-to-service calls bypass the gateway — direct communication or message queues.
  5. Start with a custom Express gateway, move to Kong or AWS API Gateway when complexity demands it.
  6. In Docker, only expose the gateway port — services communicate on the internal network.

Explain-It Challenge

  1. A junior developer adds JWT validation to every microservice instead of the gateway. What problems will this cause? How do you fix it?
  2. The gateway is a single point of failure. How do you make it highly available?
  3. 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 ->