What is it? #
An API gateway is a single entry point in front of several backend services. It routes each request to the right service and handles the concerns common to all of them.
Those shared concerns are the reason it exists: authentication, rate limiting, TLS termination, request logging, CORS and response shaping. Implementing each of those in every service is duplicated work and inconsistent in practice.
It also decouples clients from your internal layout. Clients call one address; you can split, rename or move services behind it without touching them.
The risk is concentration. A gateway that accumulates business logic becomes a bottleneck for both traffic and development.
Think of it like this #
A building reception for a complex with many offices. Visitors check in once, are directed to the right floor, and the security and visitor log are handled in one place.
If reception started making decisions on behalf of each office, every office change would require retraining reception.
Simple example #
Mobile and web clients call one domain. Behind it, five services handle catalogue, orders, payments, users and search. The gateway authenticates once and routes by path.
Code #
┌────────────────────────┐
mobile ──┐ │ API GATEWAY │
web ──┼───────────▶│ TLS, auth, rate limit │
partner──┘ │ routing, logging │
└───────┬────────────────┘
┌───────────┬─────────┼─────────┬────────────┐
▼ ▼ ▼ ▼ ▼
catalogue orders payments users search
# Routing and shared policy, expressed as configuration
routes:
- path: /api/v1/products/*
service: catalogue
auth: optional # public browsing
rate_limit: { requests: 200, per: minute, key: ip }
cache: { ttl: 60 }
- path: /api/v1/orders/*
service: orders
auth: required
rate_limit: { requests: 60, per: minute, key: user }
timeout: 5s
- path: /api/v1/payments/*
service: payments
auth: required
scopes: [payments:write]
rate_limit: { requests: 10, per: minute, key: user }
timeout: 15s
retries: 0 # never retry a charge automatically
# What the gateway does per request, in order
def handle(request):
request_id = request.headers.get("X-Request-ID") or new_id()
route = match_route(request.path) # 1. routing
if route is None:
return response(404)
if route.auth == "required": # 2. authentication
claims = verify_token(request.headers.get("Authorization"))
if claims is None:
return response(401)
if not has_scopes(claims, route.scopes): # 3. coarse authorisation
return response(403)
request.headers["X-User-Id"] = claims["sub"] # pass identity inward
if not rate_limiter.allow(route, request): # 4. rate limiting
return response(429, headers={"Retry-After": "30"})
request.headers["X-Request-ID"] = request_id # 5. tracing
upstream = forward(route.service, request, timeout=route.timeout)
log_access(request, upstream, request_id) # 6. logging
return upstream
How it works #
Routing is matched by path, and often also by host or header. The client knows one address; the mapping to services is configuration.
Authentication happens once at the edge. The gateway verifies the token and passes a verified identity header inward, so services do not each implement token validation.
That inward trust must be protected. Services should accept identity headers only from the gateway — on a private network, or by verifying a signature — otherwise anyone who can reach a service directly can impersonate any user.
Authorisation at the gateway is coarse: does this token carry the scope for this route. Fine-grained rules, such as whether this user owns this specific order, belong in the service that holds the data.
Rate limits differ per route, which is exactly the point. Public browsing can be generous; payment endpoints should not be.
retries: 0 on payments is deliberate. An automatic retry of a charge risks a double payment, so retries there must be explicit and idempotency-keyed.
The request ID is generated or propagated, then passed to every service, which is what makes a distributed trace possible.
Real-world use #
Kong, Envoy, Traefik, NGINX and managed cloud gateways all fill this role. In Kubernetes, an ingress controller or service mesh gateway does the same job.
The backend-for-frontend pattern extends the idea: a separate gateway per client type, so the mobile API can aggregate several service calls into one response suited to a phone, without complicating the web API.
That aggregation is genuinely useful on mobile networks, where each round trip is expensive. It is also where gateways start to accumulate logic, which is the main thing to guard against.
A gateway is on the critical path for every request, so it needs the same redundancy and monitoring as any other tier — usually more.
For a single service, a gateway is unnecessary. A reverse proxy already provides TLS, routing and basic limits, which is all you need until there are several services to unify.
Common mistakes #
- Putting business logic in the gateway, turning it into a deployment bottleneck.
- Services trusting identity headers from any source, allowing impersonation.
- Running a single gateway instance, creating a system-wide single point of failure.
- Automatically retrying non-idempotent requests such as payments.
- Adding a gateway for a single service, where a reverse proxy would do.
Practice #
Write the routing table for four services with different authentication requirements, rate limits and timeouts. Mark which routes are public, which need scopes, and which must never be retried. Then state how the services will verify that a request genuinely came through the gateway.