Ch. 10 · Microservices

Microservice API Gateways and Edge Responsibilities

What an API gateway should own (TLS, token checks, routing, rate limits) and why refund rules and workflows belong in the services behind it.

~7 min readintermediateupdated Oct 7, 2026

An API gateway is the single entry point that external clients talk to. A mobile app calls api.example.com/invoices/inv-7/refund, and the gateway decides which internal service receives that request, after checking that the caller presented a valid token and hasn’t exceeded their rate limit. Without one, every client would need to know the address of every service, and every service would repeat TLS termination, CORS, token parsing and throttling.

The interview question is rarely “what is a gateway”. It’s “what should go in it, and what shouldn’t”. The answer that separates strong candidates is a clear line between edge concerns, which are the same for every route, and business rules, which belong to one service.

The work that genuinely belongs at the edge

Think about what’s identical regardless of which service handles the request:

  • TLS termination and certificates. One place to rotate certs instead of forty.
  • Authentication. Is this a valid, unexpired token signed by our identity provider? That check doesn’t depend on whether the request is about invoices or shipments.
  • Routing. /invoices/** goes to billing, /orders/** goes to orders. Path rewriting and version routing live here too.
  • Rate limiting and quotas. Per API key or per user, applied before anything expensive runs.
  • Request hygiene. Maximum body size, header normalisation, rejecting malformed requests early, adding a request id and the trace context.
  • Response concerns. Compression, CORS headers, a consistent error envelope for 401/429/503.

Each of these is generic. Configuration changes when you add a route, but the gateway’s code doesn’t need to know what a refund is. That’s the test to apply: if a feature requires the gateway to understand a domain concept, it probably belongs somewhere else.

In Spring Cloud Gateway, that edge job is mostly configuration. This snippet is illustrative, but the shape matches the reference docs:

spring:
  cloud:
    gateway:
      routes:
        - id: billing
          uri: lb://billing-service
          predicates:
            - Path=/invoices/**
          filters:
            - name: RequestRateLimiter
              args:
                redis-rate-limiter.replenishRate: 20
                redis-rate-limiter.burstCapacity: 40
yaml

Nothing in that route says who may refund an invoice. That’s deliberate.

A worked example: who may refund invoice inv-7?

Here’s a small, runnable model of the split, written with Node’s http module. Billing owns invoices. Invoice inv-7 belongs to customer c-42. Support staff may refund anything; a customer may refund only their own invoice. The gateway knows none of this.

// Billing service: owns invoices and decides who may refund which one.
const invoices = { 'inv-7': { customerId: 'c-42', amount: 1999 } };
const billing = http.createServer((req, res) => {
  const claims = verify(req.headers.authorization?.replace('Bearer ', ''));
  if (!claims) { res.writeHead(401).end('no valid token'); return; }
  const id = req.url.split('/')[2];
  const inv = invoices[id];
  if (!inv) { res.writeHead(404).end(); return; }
  const allowed = claims.role === 'support' || claims.sub === inv.customerId;
  if (!allowed) { res.writeHead(403).end(`${claims.sub} may not refund ${id}`); return; }
  res.writeHead(200).end(`refunded ${id}`);
});

// Gateway: authenticates, routes. No refund rules here.
const routes = [{ prefix: '/invoices/', port: 4102 }];
const gateway = http.createServer((req, res) => {
  if (!verify(req.headers.authorization?.replace('Bearer ', ''))) {
    res.writeHead(401).end('gateway: bad token'); return;
  }
  const route = routes.find((r) => req.url.startsWith(r.prefix));
  if (!route) { res.writeHead(404).end(); return; }
  const up = http.request({ port: route.port, path: req.url, method: req.method,
    headers: { authorization: req.headers.authorization } }, (u) => {
    res.writeHead(u.statusCode); u.pipe(res);
  });
  req.pipe(up);
});
JavaScript

verify checks an HMAC-signed token (a stand-in for JWT validation). Running four calls against it in Node 22 printed:

owner via gateway                      200 refunded inv-7
other customer via gateway             403 c-99 may not refund inv-7
no token via gateway                   401 gateway: bad token
other customer direct to billing       403 c-99 may not refund inv-7
Text

Two details matter. First, the 401 came from the gateway (cheap rejection at the edge), but the 403 came from billing, because only billing knows who owns inv-7. Second, look at the last line. Mallory skipped the gateway entirely and hit billing on its internal port, and still got a 403. That’s the property you want.

Why the last line is the one interviewers care about

Many designs authenticate at the gateway and then let internal services trust anything that arrives. That works until a request arrives by another path: a Kafka consumer that triggers refunds, a batch job, an admin tool on the internal network, another service calling billing directly, or an attacker who has compromised one pod and can now reach every service. None of those go through the gateway.

The usual phrase for the bad version is “perimeter security”: hard shell, soft centre. The fix is that each service validates the token itself (signature, expiry, audience), or trusts identity only from a source it can verify, such as mTLS from the service mesh plus a signed token forwarded from the edge. Validating a JWT locally costs microseconds once the signing keys are cached, so “the gateway already did it” isn’t a performance argument.

A related mistake is having the gateway strip the token and forward a plain X-User-Id: c-42 header. Any internal caller can set that header. If you do pass derived identity, sign it, or make sure the network path makes spoofing impossible and the service checks the source.

How a gateway turns into a distributed monolith

The slow failure is scope creep. It starts with “the mobile home screen needs data from orders, recommendations and loyalty, so let’s aggregate in the gateway”. Then someone adds “if the order is over 500 euros, also call fraud-check”. Then a discount rule. A year later every product change needs a gateway deployment, the gateway team is a bottleneck, and the gateway holds business rules that nobody can find in the owning service.

Some guidelines that hold up:

  • Aggregation for one client belongs in a backend-for-frontend. A BFF is a small service owned by the team that builds that client. It can compose calls freely because it serves one consumer. The shared gateway stays generic.
  • Workflows belong to a service or a saga. “Reserve stock, charge card, schedule delivery” is order-domain logic. Put it in the order service, with compensation if a step fails.
  • Don’t validate business payloads at the edge. Checking that the body is valid JSON under 1 MB is fine. Checking that quantity <= stock is not; the gateway would need stock data it doesn’t own.
  • Keep transformations shallow. Header mapping and path rewrites are fine. Field-by-field response rewriting that encodes domain knowledge is a sign the API contract itself needs fixing.

The gateway is also a single point of failure and a shared bottleneck. Run several instances behind a load balancer, keep its own dependencies minimal (if the rate limiter’s Redis is down, decide in advance whether to fail open or closed), and give each route its own timeouts so one slow backend can’t tie up every gateway connection.

Coarse checks at the edge, fine checks in the service

There’s a reasonable middle ground on authorization. The gateway can enforce coarse, route-level policy: “only tokens with scope billing:write may POST to /invoices/**”. That’s still generic (it reads a claim and matches a route) and it rejects obviously wrong requests early. What it can’t do is answer object-level questions, like whether this user owns this invoice, whether the invoice is already refunded, or whether the refund exceeds the original charge. Those need billing’s data.

So the honest description is defence in depth: the gateway filters, the service decides. If the gateway’s coarse check were removed tomorrow, the system should still be secure. It would just do more work in billing.

Interview practice: the refund that skipped the gateway

“Your team authenticates and checks scopes at the API gateway. Services trust the X-User-Id header it adds. A security review finds that a nightly reconciliation job, which runs inside the cluster, issued refunds for invoices that didn’t belong to the account it was processing. What went wrong, and what would you change?”

Answer

The job called billing directly, not through the gateway, and billing had no authorization of its own. It trusted whatever X-User-Id it received, and the job either set the wrong value or none was required. The gateway’s checks only protected one entry path.

I’d change three things. Billing validates caller identity on every request, regardless of path: a signed token for user requests, and a workload identity (mTLS certificate or a service token with its own scopes) for jobs. Billing performs the object-level check itself: does this principal own inv-7, or does it hold a role that permits refunds across accounts, and is the invoice in a refundable state. And I’d stop forwarding unsigned identity headers; if derived identity must travel, it travels as a signed token billing can verify.

I’d keep the gateway’s scope check. It’s useful early rejection. But I’d describe it as an optimisation, not the security boundary. I’d also add a test that calls billing directly with another customer’s token and expects a 403, so the property doesn’t regress.

Where to go next

More in Microservices

esc