API Gateway: Patterns, Benefits & Implementation

Everything you need to know about API gateways — what they do, core patterns, tool comparison (Kong, AWS, Nginx, Traefik), and building one with Node.js.

Last Updated:

What Is an API Gateway?

An API gateway is a server that sits between your clients and your backend services, acting as the single entry point for all API traffic. It intercepts every request, applies cross-cutting policies, and routes traffic to the appropriate upstream service.

Without a gateway:
  Mobile App  ──► User Service :3001
  Web App     ──► Order Service :3002     (each client knows all services)
  Partner API ──► Payment Service :3003

With a gateway:
  Mobile App  ┐
  Web App     ├──► API Gateway :443 ──► User Service :3001
  Partner API ┘                    ├──► Order Service :3002
                                   └──► Payment Service :3003

  Gateway handles: auth, rate limiting, SSL, logging, routing

The gateway centralises concerns that every service would otherwise need to implement independently. It is a foundational pattern in microservices architecture.

Gateway vs Reverse Proxy vs Load Balancer

ComponentPrimary JobAPI-Specific Features
Load Balancer (L4)Distribute TCP connectionsNone — protocol-agnostic
Reverse Proxy (L7)Forward HTTP requests, SSL terminationBasic URL routing
API Gateway (L7+)All reverse proxy features plus API policiesAuth, rate limiting, transformation, analytics, versioning

Core Responsibilities

  • SSL/TLS termination: Decrypt HTTPS at the gateway; internal services communicate over plain HTTP on a private network
  • Authentication & authorization: Validate JWT tokens, API keys, or OAuth tokens once — upstream services receive a verified identity
  • Rate limiting: Enforce per-client, per-endpoint, or global request quotas
  • Request routing: Map public URLs to internal service URLs (e.g., /api/v2/usersuser-service:3001/users)
  • Load balancing: Distribute traffic across multiple instances of a service
  • Request/response transformation: Add headers, rewrite paths, translate protocols, aggregate responses
  • Logging & tracing: Inject request IDs, capture access logs, forward spans to distributed tracing systems
  • Caching: Cache GET responses at the gateway to reduce upstream load
  • Circuit breaking: Stop forwarding requests to an unhealthy upstream service
  • API versioning: Route /v1/* to legacy services and /v2/* to new services

Authentication & Authorization at the Gateway

Centralising auth at the gateway means your upstream services can trust the gateway's forwarded identity headers instead of validating tokens themselves — simplifying each service and ensuring consistent enforcement.

// Gateway auth middleware — validates JWT, injects identity headers
const jwt = require('jsonwebtoken');

function authMiddleware(req, res, next) {
  const token = req.headers.authorization?.replace('Bearer ', '');
  if (!token) return res.status(401).json({ error: 'Missing token' });

  try {
    const payload = jwt.verify(token, process.env.JWT_PUBLIC_KEY, {
      algorithms: ['RS256']
    });

    // Inject verified identity into headers for upstream services
    req.headers['x-user-id']    = payload.sub;
    req.headers['x-user-roles'] = payload.roles.join(',');
    req.headers['x-tenant-id']  = payload.tenant_id;

    // Remove the raw token — upstream services don't need it
    delete req.headers['authorization'];
    next();
  } catch (err) {
    res.status(401).json({ error: 'Invalid or expired token' });
  }
}

// Apply to all routes except public ones
gateway.use((req, res, next) => {
  const publicPaths = ['/health', '/auth/login', '/auth/refresh'];
  if (publicPaths.some(p => req.path.startsWith(p))) return next();
  authMiddleware(req, res, next);
});

API Key Authentication

// API key validation with Redis for performance
const redis = require('redis').createClient();

async function apiKeyMiddleware(req, res, next) {
  const key = req.headers['x-api-key'] || req.query.api_key;
  if (!key) return res.status(401).json({ error: 'API key required' });

  // Check Redis cache first (avoid DB hit on every request)
  let client = await redis.get('apikey:' + key);
  if (!client) {
    // Look up in database
    client = await db.apiKeys.findOne({ key, active: true });
    if (client) await redis.setEx('apikey:' + key, 300, JSON.stringify(client));
  } else {
    client = JSON.parse(client);
  }

  if (!client) return res.status(401).json({ error: 'Invalid API key' });

  req.headers['x-client-id']   = client.id;
  req.headers['x-client-plan'] = client.plan;
  next();
}

Rate Limiting & Throttling

The gateway is the right place to enforce rate limits — it sees all traffic before it reaches your services, and a single Redis instance can track counters across all gateway instances.

// Sliding window rate limiter at the gateway
const redis = require('redis').createClient();

async function rateLimiter(limit, windowMs) {
  return async (req, res, next) => {
    const key = 'rl:' + (req.headers['x-client-id'] || req.ip);
    const now  = Date.now();
    const win  = Math.floor(now / windowMs);

    const count = await redis.incr(key + ':' + win);
    if (count === 1) await redis.expire(key + ':' + win, Math.ceil(windowMs / 1000) * 2);

    res.setHeader('X-RateLimit-Limit',     limit);
    res.setHeader('X-RateLimit-Remaining', Math.max(0, limit - count));
    res.setHeader('X-RateLimit-Reset',     (win + 1) * windowMs);

    if (count > limit) {
      res.setHeader('Retry-After', Math.ceil(windowMs / 1000));
      return res.status(429).json({
        error: 'Too Many Requests',
        retry_after: Math.ceil(windowMs / 1000)
      });
    }
    next();
  };
}

// Different limits per plan
gateway.use('/api/', async (req, res, next) => {
  const plan  = req.headers['x-client-plan'] || 'free';
  const limit = { free: 100, pro: 1000, enterprise: 10000 }[plan] || 100;
  return rateLimiter(limit, 60_000)(req, res, next); // per minute
});

For a full deep-dive on rate limiting algorithms (token bucket, sliding window, fixed window), see our Rate Limiting Guide.

Routing & Load Balancing

The gateway maps public API paths to internal service URLs, handles path rewriting, and distributes load across service instances.

const { createProxyMiddleware } = require('http-proxy-middleware');

// Route table — maps public paths to internal services
const routes = [
  {
    path: '/api/v1/users',
    target: 'http://user-service:3001',
    pathRewrite: { '^/api/v1': '' }   // /api/v1/users/42 → /users/42
  },
  {
    path: '/api/v1/orders',
    target: 'http://order-service:3002',
    pathRewrite: { '^/api/v1': '' }
  },
  {
    path: '/api/v2/users',
    target: 'http://user-service-v2:3004',   // v2 routes to new service
    pathRewrite: { '^/api/v2': '' }
  }
];

routes.forEach(({ path, target, pathRewrite }) => {
  gateway.use(path, createProxyMiddleware({
    target,
    changeOrigin: true,
    pathRewrite,
    on: {
      error: (err, req, res) => {
        res.status(502).json({ error: 'Service unavailable' });
      }
    }
  }));
});

Weighted Load Balancing

// Canary deploy: send 10% of traffic to v2
function weightedRouter(targets) {
  return (req, res, next) => {
    const rand = Math.random() * 100;
    let cumulative = 0;
    for (const t of targets) {
      cumulative += t.weight;
      if (rand <= cumulative) {
        req.target = t.url;
        return next();
      }
    }
  };
}

gateway.use('/api/payments', weightedRouter([
  { url: 'http://payment-v1:3003', weight: 90 },  // 90% to stable
  { url: 'http://payment-v2:3005', weight: 10 }   // 10% to canary
]));

Request & Response Transformation

Gateways can reshape requests and responses — adding headers, translating formats, or aggregating data from multiple services into a single response.

// Add tracing headers to every upstream request
gateway.use((req, res, next) => {
  const { randomUUID } = require('crypto');
  req.headers['x-request-id']   = req.headers['x-request-id'] || randomUUID();
  req.headers['x-forwarded-for'] = req.ip;
  req.headers['x-gateway-time'] = Date.now().toString();
  next();
});

// Response transformation — strip internal headers, add versioning
gateway.use((req, res, next) => {
  const originalSend = res.json.bind(res);
  res.json = (body) => {
    // Add API version envelope
    originalSend({
      data:       body,
      api_version: '1.0',
      request_id:  req.headers['x-request-id']
    });
  };
  next();
});

Gateway Comparison: Kong, AWS, Nginx, Traefik

GatewayTypeBest ForAuthRate LimitingCost
Kong OSS / Enterprise Plugin ecosystem, Kubernetes-native, polyglot JWT, OAuth2, Key Auth plugins Built-in plugin Free OSS / Paid Enterprise
AWS API Gateway Managed cloud AWS Lambda + REST, serverless architectures Cognito, IAM, Lambda authorizers Built-in usage plans Pay-per-request
Nginx OSS reverse proxy High-performance, custom Lua scripting Custom (Lua/ngx_http_auth) limit_req module Free (OSS) / Paid Plus
Traefik OSS cloud-native Docker/Kubernetes auto-discovery Forward auth middleware Rate limit middleware Free OSS / Paid Enterprise
Envoy Proxy OSS CNCF Service mesh, gRPC, advanced load balancing External auth filter Built-in local/global Free (OSS)
Azure API Mgmt Managed cloud Enterprise, Azure ecosystem, developer portal OAuth2, subscription keys Built-in policies Pay per unit

Choosing a Gateway

  • Kubernetes on any cloud → Kong or Traefik
  • AWS + Lambda/serverless → AWS API Gateway
  • Maximum performance, custom logic → Nginx + OpenResty (Lua)
  • Service mesh + gRPC → Envoy (via Istio or standalone)
  • Small team, fast start → Traefik (zero-config with Docker labels)
  • Enterprise with developer portal → Kong Enterprise or Azure API Management

Complete Node.js Gateway Example

// gateway.js — production-ready API gateway skeleton
const express  = require('express');
const proxy    = require('http-proxy-middleware');
const jwt      = require('jsonwebtoken');
const redis    = require('redis').createClient({ url: process.env.REDIS_URL });
const pino     = require('pino');

const logger  = pino();
const gateway = express();

await redis.connect();

// 1. Request logging
gateway.use((req, res, next) => {
  const start = Date.now();
  res.on('finish', () => logger.info({
    method: req.method, path: req.path,
    status: res.statusCode, ms: Date.now() - start,
    requestId: req.headers['x-request-id']
  }));
  next();
});

// 2. Request ID injection
gateway.use((req, _, next) => {
  req.headers['x-request-id'] ||= require('crypto').randomUUID();
  next();
});

// 3. JWT auth (skip public paths)
const PUBLIC = new Set(['/health', '/auth/token']);
gateway.use(async (req, res, next) => {
  if (PUBLIC.has(req.path)) return next();
  const token = req.headers.authorization?.split(' ')[1];
  if (!token) return res.status(401).json({ error: 'Unauthorized' });
  try {
    const p = jwt.verify(token, process.env.JWT_SECRET);
    req.headers['x-user-id']   = p.sub;
    req.headers['x-user-roles'] = (p.roles || []).join(',');
    next();
  } catch { res.status(401).json({ error: 'Invalid token' }); }
});

// 4. Rate limiting — 100 req/min per user
gateway.use(async (req, res, next) => {
  if (PUBLIC.has(req.path)) return next();
  const key  = 'rl:' + (req.headers['x-user-id'] || req.ip);
  const win  = Math.floor(Date.now() / 60000);
  const cnt  = await redis.incr(key + ':' + win);
  if (cnt === 1) await redis.expire(key + ':' + win, 120);
  res.setHeader('X-RateLimit-Limit', 100);
  res.setHeader('X-RateLimit-Remaining', Math.max(0, 100 - cnt));
  if (cnt > 100) return res.status(429).json({ error: 'Rate limit exceeded' });
  next();
});

// 5. Route to services
const services = {
  '/users':    'http://user-service:3001',
  '/orders':   'http://order-service:3002',
  '/payments': 'http://payment-service:3003'
};
Object.entries(services).forEach(([path, target]) => {
  gateway.use('/api/v1' + path, proxy.createProxyMiddleware({
    target, changeOrigin: true,
    pathRewrite: { '^/api/v1': '' }
  }));
});

// 6. Health check
gateway.get('/health', (_, res) => res.json({ status: 'ok' }));

gateway.listen(8080, () => logger.info('Gateway running on :8080'));

Frequently Asked Questions

Does an API gateway become a single point of failure?

It can — which is why you run multiple gateway instances behind a cloud load balancer and make the gateway stateless (push state to Redis or a distributed cache). All major managed gateways (AWS API Gateway, Kong Cloud) are designed for high availability out of the box. For self-hosted gateways, deploy at least two instances across availability zones.

Should the gateway do business logic?

No. The gateway should handle only cross-cutting infrastructure concerns. Business logic belongs in your services. If you find yourself putting if/else business rules in the gateway, that logic belongs in a dedicated service or the appropriate microservice.

How is an API gateway different from a service mesh?

An API gateway manages north-south traffic — traffic entering your system from clients. A service mesh (Istio, Linkerd) manages east-west traffic — communication between services inside your cluster. They are complementary: the gateway is the front door, the service mesh handles what happens inside. In large Kubernetes deployments, both are used together.

What is a "micro-gateway" or sidecar gateway?

A sidecar gateway runs as a proxy container alongside each service pod (like Envoy in Istio). Instead of a single central gateway, each service has its own proxy that handles auth and rate limiting. This gives more granular control and eliminates the central choke point, but increases operational complexity significantly.