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: 40Nothing 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);
});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-7Two 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 <= stockis 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
- Microservices interview questions
- Microservices MCQs
- Backend for frontend
- Sidecar proxies
- Pattern reference: API gateway on microservices.io