API Gateway
By the end of this lesson
Give clients one entry point to many services.
An API gateway is one address that clients call, which forwards each request to whichever service owns it. The browser asks api.example.com for an order; the gateway decides that orders lives at orders.internal and passes the request on.
The problem it solves is what clients otherwise have to know. Without it, a front end holds the address of every service, handles a different authentication setup for each, needs cross-origin permission from each, and has to be redeployed when a service moves or splits. Concerns that are identical for every service — checking the token, limiting the request rate, terminating TLS, attaching a correlation identifier — end up implemented several times, slightly differently.
### What the client sends
GET https://api.example.com/orders/8421 HTTP/1.1
Authorization: Bearer <access-token>
Accept: application/json
### What the orders service receives
GET http://orders.internal:8080/orders/8421 HTTP/1.1
Accept: application/json
X-Correlation-Id: 0f4c1a7e-6b2d-4f19-9a33-1c7e85d2b4aa
X-Forwarded-For: 203.0.113.42
X-Forwarded-Proto: https
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01- The client knows one host name. Services can move, be renamed, or be split in two without the front end being rebuilt.
- The token was validated at the edge, once. Services behind the gateway still check it, because an internal caller can reach them without passing through the gateway at all.
- The correlation and trace headers are attached here because this is the first point that sees the request. Add them further in and every log line before that point is unlinked from the rest.
- The caller's address now only exists in X-Forwarded-For. Services behind the gateway have to be configured to read forwarded headers, or every log entry records the gateway as the client and rate limiting by address becomes meaningless.
- Whatever the gateway adds, it must also remove from inbound requests. If a client can send its own X-Correlation-Id it can poison your logs; if it can send a header your services trust for identity, it can act as anyone. Strip first, then set.
{
"ReverseProxy": {
"Routes": {
"orders": {
"ClusterId": "orders",
"Match": { "Path": "/orders/{**remainder}" },
"AuthorizationPolicy": "authenticated",
"RateLimiterPolicy": "per-user",
"Transforms": [ { "RequestHeaderRemove": "X-Correlation-Id" } ]
},
"invoices": {
"ClusterId": "invoicing",
"Match": { "Path": "/invoices/{**remainder}" },
"AuthorizationPolicy": "finance-only",
"RateLimiterPolicy": "per-user"
}
},
"Clusters": {
"orders": {
"LoadBalancingPolicy": "RoundRobin",
"HealthCheck": {
"Active": { "Enabled": true, "Path": "/health/ready", "Interval": "00:00:10" }
},
"Destinations": {
"orders-1": { "Address": "http://orders-1.internal:8080/" },
"orders-2": { "Address": "http://orders-2.internal:8080/" }
}
},
"invoicing": {
"Destinations": {
"invoicing-1": { "Address": "http://invoicing.internal:8080/" }
}
}
}
}
}- A route matches a path and names a cluster; a cluster lists where that service actually runs. Adding a second copy of orders is one line of configuration rather than a code change anywhere.
- The authorisation policy is per route, so a valid token without the finance claim is refused at the edge and the invoicing service never sees the request. The service still applies its own rules, because this is a filter and not a substitute.
- Active health checks take a restarting instance out of rotation. Without them the gateway keeps sending traffic to a process that is not ready and the client sees intermittent errors for as long as the restart takes.
- The header removal is a trust boundary in one line. The gateway sets the correlation identifier, so any value the client supplied has to go first.
- This being configuration is both the appeal and the risk. A route change can ship without a build — and also without a compiler, a test or a code review unless you insist on one. Keep it in version control and treat it as code.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddJwtBearer();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("authenticated", p => p.RequireAuthenticatedUser())
.AddPolicy("finance-only", p => p.RequireClaim("role", "finance"));
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("per-user", context => RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.User.FindFirst("sub")?.Value ?? "anonymous",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120,
Window = TimeSpan.FromMinutes(1),
}));
});
builder.Services.AddReverseProxy()
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
var app = builder.Build();
app.UseForwardedHeaders();
app.UseAuthentication();
app.UseAuthorization();
app.UseRateLimiter();
app.MapReverseProxy();
app.Run();- Token validation and the policies are declared once. A new service placed behind the gateway inherits them by being routed through, rather than by copying startup code.
- The rate limit is keyed on the user identifier from the token. Keying on the caller's address instead throttles an entire office as a single client, which is a support call waiting to happen.
- Middleware order is load-bearing. Forwarded headers first, so everything after it sees the real caller. Authentication before authorisation, because a policy needs an identity. Rate limiting after both, so the limit has a user to count against.
- The anonymous fallback deserves a second look before production: one shared partition means every unauthenticated caller competes for the same allowance, which is either the protection you want or a denial of service you built yourself.
- This file is short and should stay short. Everything in it applies to every service behind it, so each addition is a change to all of them at once.
What belongs at the edge, and what does not:
- Routing and instance selection
- Belongs. This is the gateway's reason to exist: clients address capabilities, not machines.
- Token validation and TLS termination
- Belongs. Identical for every service and easy to get subtly wrong when repeated. Services still verify the token themselves — the edge check is a filter, not a guarantee.
- Rate limiting and request size limits
- Belongs. Protection is most useful before the work starts, and a limit applied per service cannot see a caller hammering four of them.
- Correlation and tracing headers
- Belongs. The edge is the only place that sees every request, so it is the only place that can guarantee every request has an identifier.
- Coarse authorisation
- Belongs, if it can be decided from the token alone. "Has the finance role" is a claim check. "May approve invoices over ten thousand pounds" needs domain data and belongs in the service.
- Business rules
- Does not belong. The moment the gateway knows what an invoice is, it becomes a service that every team must change and nobody owns.
- Aggregating several services into one response
- Judge this carefully. It is genuinely useful for a mobile client on a slow connection, and it makes the gateway as fragile as the slowest thing it calls. Prefer a backend-for-frontend service, owned by the client team, sitting behind the gateway.
- Response transformation per feature
- Does not belong. Reshaping one service's payload for one screen is logic with no tests and no owner, in the one component that cannot be allowed to fail.
Clients calling services directly, against calling through a gateway:
| Direct to services | Through a gateway | |
|---|---|---|
| Addresses a client knows | One per service | One |
| Moving or splitting a service | Client release | Configuration change |
| Token validation | Implemented per service | Once at the edge, verified again per service |
| Rate limiting a noisy caller | Per service, blind to the total | One place that sees every request |
| Cross-origin and TLS setup | Per service | Once |
| Extra network hop | None | One, a few milliseconds, plus a place headers can be lost |
| If it fails | One capability is unavailable | Everything is unavailable |
| Adding a route | Nothing to change centrally | A shared file that needs an owner and a review |
Summary
- A gateway gives clients one address and holds the concerns that are identical for every service
- Routing, TLS, token validation, rate limits and correlation identifiers belong at the edge
- Business rules and per-feature reshaping do not; a backend-for-frontend is the better home
- Everything passes through it, so it needs redundancy, health checks and monitoring of its own
- Shared configuration can become a release queue, and one service does not need a gateway at all
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Think about it
Edge or service?
Decide where each of these belongs, and say why: validating the access token's signature; checking that this user may approve invoices over ten thousand pounds; limiting a caller to 120 requests a minute; converting an invoice total to the customer's currency; refusing requests with a body larger than two megabytes.
Show solution
Signature validation: the edge, and the service as well. It is identical everywhere, so centralising it stops five slightly different implementations, but a service that skips its own check trusts the network, and the network is not a security boundary.
The ten thousand pound approval limit: the service. It needs the invoice, the user's authority and probably a policy table. A gateway that can answer it has a copy of your domain rules in the one component everybody depends on.
The rate limit: the edge, keyed on the user from the token. Only the edge sees every request from one caller across all services, which is the whole point of counting them.
Currency conversion: the service that owns invoicing. It is business logic, it needs rates and rounding rules, and it needs testing. None of that belongs in a routing component.
The size limit: the edge, and again in the service. Rejecting two megabytes before the work starts protects everything behind it, and a service exposed to internal callers still needs its own limit.
The pattern that emerges: concerns decided from the request alone go at the edge; anything needing domain data stays in the service. Defence in depth means the cheap checks often happen in both places, and that duplication is deliberate.
Try it yourself
Try to impersonate someone
In a test environment, send a request through the gateway with headers your services trust — a correlation identifier, and if any service reads one, a tenant or user header.
Inspect what the service actually received. Then send the same request directly to the service, bypassing the gateway.
Show solution
If your headers arrived unchanged, the gateway is passing through values it should own. The fix is to remove them on the inbound side and then set them, in that order, because a transform that only sets is bypassed by a client that sends the header twice.
The direct call is the more important half of the exercise. It usually succeeds, which tells you the service is protected by network configuration rather than by anything it does itself. That is worth knowing before someone adds a debugging tool, a test pod or a new subnet.
The correlation identifier looks harmless and is not. A client that can choose it can make its requests share an identifier with somebody else's, and your investigation of an incident then mixes two users' activity into one trace.
Write both cases as automated tests. This class of defect is invisible in code review — nothing is wrong in any one file, the protection is merely absent — and it reappears whenever the gateway configuration is refactored.
Saved in this browser only.