TL;DR: Centralizing authentication at the gateway is close to a free win — validate the JWT once, stop re-implementing token checks in every service. Centralizing rate limiting is a genuine architecture decision with a real failure mode (fail-open vs. fail-closed when the limiter is unreachable). Centralizing aggregation — turning the gateway into a BFF — is the one that should make you nervous, because it's the pattern most likely to turn your gateway into the monolith you were trying to avoid.

If you've built more than one microservices system, you've had some version of this conversation: "should the gateway just handle that?" It usually comes up around three capabilities — authentication, rate limiting, and response aggregation — and the answer is different for each one. Treating them as a single "gateway responsibilities" bucket is where teams get into trouble. Below is how we'd reason through each, with the failure modes that don't show up until production traffic finds them.

The gateway's actual job

An API gateway sits between clients and a set of backend services and does some combination of: routing, protocol translation, authentication/authorization, rate limiting, request/response transformation, and observability. The core value proposition is a single enforcement point instead of N inconsistent ones (Practical DevSecOps, 2026).

The core risk is the same sentence read backwards: a single enforcement point is also a single point of failure, and a single thing every team depends on tends to accrete responsibilities until it becomes the thing everyone is scared to deploy.

So the right question per capability isn't "does a gateway do this well?" — most gateways technically can. It's "what happens to my system when this shared component is degraded, misconfigured, or down?"

A quick decision table

CapabilityCentralize?Failure mode if centralized and it breaksFailure mode if left in-service
AuthN (identity verification)Yes, almost alwaysAll traffic gets 401s until fixedInconsistent validation logic drifts across services
AuthZ (fine-grained permissions)Usually no — keep coarse-grained at gateway, fine-grained in-serviceGateway becomes a business-logic bottleneckDuplicated policy logic, but locality of context
Rate limitingYes, with a defined fail-open/fail-closed policyEither open floodgates or false 429s system-wideBackend gets hit directly by abusive clients before limiter engages
Response aggregation (BFF-style)Selectively, per client typeGateway becomes a monolith; deploys couple to every backend teamClient makes N round trips; more client-side complexity

Centralizing authentication: close to a default yes

JWT-based authentication validated at the gateway is the standard pattern in 2026: the gateway checks the signature against the identity provider's public key (JWKS endpoint) on every request, and only forwards requests carrying a valid, unexpired token. Services downstream trust the gateway's validation and read claims out of the forwarded token or a trusted header (Practical DevSecOps, 2026; AskAntech).

The reasoning holds up because token validation is stateless, cacheable (JWKS keys rotate infrequently and can be cached for minutes), and identical for every service. There's no meaningful "context" a downstream service brings to the question "is this signature valid and not expired." Duplicating that logic in five services means five slightly different implementations, five sets of edge cases (clock skew handling, algorithm confusion attacks, key rotation races), and five places for a bug to hide.

Where teams get this wrong: conflating authentication (who is this) with authorization (what can they do). The widely repeated 2026 guidance is "gateway handles authentication, services handle authorization" — and that split matters because authorization decisions often need business context the gateway doesn't have (is this user the owner of this specific resource? are they in the org that owns this specific tenant row?). Pushing that logic into the gateway means the gateway now needs to know about your domain model, and every new authorization rule becomes a gateway deployment instead of a service-level code change.

A workable middle ground: the gateway enforces coarse-grained authorization — role membership, scope checks, "is this endpoint even reachable by this client type" — and defers row-level, tenant-level, and resource-ownership checks to the service that owns that data.

text
# Gateway layer (coarse-grained)
if not jwt.valid or jwt.expired:
    return 401
if "billing:read" not in jwt.scopes and request.path.startswith("/billing"):
    return 403

# Service layer (fine-grained, needs domain context)
if invoice.tenant_id != jwt.tenant_id:
    return 403  # gateway has no way to know this without querying the domain

Centralizing rate limiting: get the failure policy right before the algorithm

Rate limiting is where "centralize it" stops being obviously correct and starts being an actual engineering decision, because the choice of failure mode determines what happens to your entire API surface when the limiter itself is unhealthy.

Algorithm choice is the less important decision. Token bucket (allow controlled bursts, refill at a steady rate) is the most common default — Stripe's payment API and AWS API Gateway both use variants of it — because it tolerates legitimate burst traffic while still enforcing an average rate (API7.ai; Redis). Sliding window counters give more accurate enforcement (no boundary-burst problem where a client sends 2x their limit split across a fixed-window edge) at a modest memory cost, and are a reasonable choice when precision matters more than raw throughput. For most APIs, either is fine; don't spend a design review on this part. We go deeper on the header contract and precise semantics of each algorithm in Rate Limiting as a Contract — worth reading before you pick one.

Fail-open vs. fail-closed is the important decision, and most teams don't make it consciously. When the rate-limiting service or its backing store (usually Redis) is unreachable, the gateway has to decide: let the request through (fail open) or reject it (fail closed). Envoy's documented default behavior is to fail open — if its external rate-limit service is unreachable, requests proceed unthrottled (case study referenced via Medium/CodeToDeploy, 2026). That's usually the right default for availability — a rate limiter outage shouldn't take down your whole API — but it means an attacker who can degrade your Redis cluster gets unlimited rate as a side effect. If your rate limiting exists primarily for abuse prevention rather than fairness, fail-closed with a tight timeout might be the safer default, accepting that a limiter outage becomes a partial API outage.

Either choice is defensible. The mistake is not deciding — leaving it at whatever the gateway ships with by default and finding out which one you got during an incident.

Where to apply limits. Rate limiting at the gateway catches abusive clients before their traffic reaches backend services at all, which is strictly better than limiting inside each service after the fact (API7.ai). Apply limits at multiple granularities simultaneously — per-IP as a blunt DDoS backstop, per-API-key for the primary contract with integration partners, and per-endpoint for expensive operations (search, export, bulk write) that need tighter limits than the account-level default.

Centralizing aggregation: the pattern most likely to bite you

This is the one to be most careful with, because "let the gateway combine three backend calls into one client response" sounds like an obvious efficiency win and quietly turns into a maintenance liability.

API Gateway vs. BFF: not the same pattern

An API gateway is a single shared abstraction serving every client. A Backend for Frontend (BFF) is a purpose-built aggregation layer scoped to one client experience — one BFF for the mobile app, a different one for the web dashboard, each owned by the team that owns that client (GeeksforGeeks; HackerNoon).

The distinction matters because "put aggregation logic in the gateway" collapses these two patterns into one component, and that component tends to grow in an unhealthy direction: every new client need becomes a new gateway endpoint, every new gateway endpoint needs code review from whoever owns the gateway, and the gateway team becomes the bottleneck for every frontend team's roadmap. A gateway that starts as thin routing infrastructure ends up as a monolithic aggregation API that every client must stay compatible with — the exact coupling problem microservices were meant to avoid (WunderGraph).

A BFF avoids this because it's scoped and independently owned: the mobile team's BFF can change on the mobile team's schedule without a cross-team review, because nothing else depends on it.

When gateway-level aggregation is fine

  • Truly generic transformations that apply identically to all clients — stripping internal fields, normalizing error shapes, adding correlation IDs. This isn't really "aggregation," it's cross-cutting transformation, and it's safe to centralize for the same reason authentication is.
  • A small number of stable client types where the aggregation need is narrow and unlikely to diverge (e.g., a single first-party web app with no other consumers). At that scale, a dedicated BFF is often unnecessary ceremony.
  • Read-heavy composition of a handful of services for a dashboard-style view, where the alternative (client makes 4 sequential round trips) is measurably worse for the user and the composition logic is simple (fan-out, merge, no branching business logic).

When you need a real BFF instead

  • More than one client shape (mobile, web, partner API, internal admin) with genuinely different data needs. Trying to serve all of them from one aggregation layer either bloats every response with fields only one client needs, or forces conditional logic keyed on client type — both are code smells that indicate the aggregation belongs closer to each client.
  • Aggregation logic with business rules, not just data-shape composition — if the "aggregation" involves conditionals, permission-aware field filtering, or client-specific business logic, that's domain logic wearing an aggregation costume, and it deserves its own deployable unit with its own team ownership and release cadence.
  • Independent deploy cadence is a hard requirement. If the mobile team ships weekly and the gateway is a shared resource with a monthly change window, gateway-level aggregation is actively blocking them.

A worked example: order-status endpoint

Say three services own pieces of an "order status" view: Orders (status, line items), Shipping (tracking, ETA), Billing (payment status, invoice link). A mobile app wants all three in one response to render a single screen.

Gateway-as-BFF approach: the gateway calls all three services, merges the response, and returns it. Works fine until the web dashboard team also wants order status, but with different fields (they need full line-item pricing, not a summary) and the internal admin tool wants a third shape (they need the raw service responses for debugging). Now the gateway endpoint has three response modes controlled by a query parameter, and every one of those three services' schema changes has to be coordinated through the gateway team's release process.

BFF approach: the mobile team owns a small aggregation function — either literally a small deployed service or a well-contained module in their own backend — that calls the same three services and shapes the response exactly how the mobile screen needs it. The web team does the same, independently. Both call the same underlying services through the shared gateway (which still handles auth and rate limiting centrally), but the aggregation and shaping logic lives with the team that has to change it most often.

The shared gateway didn't disappear in the BFF approach — it still does the things that are genuinely uniform across all traffic (auth, rate limiting, routing, observability). What moved out was the part that was never actually uniform.

Common failure modes to design against

  • Gateway as unversioned dependency. If the gateway's aggregation contract changes, every client breaks simultaneously with no migration window. Apply the same versioning discipline to gateway-composed responses that you'd apply to any public API.
  • Retry amplification. A client retry against the gateway can turn into 3 downstream retries if the gateway naively retries each backend call on timeout. Make gateway-level retries idempotency-aware: generating and forwarding idempotency keys for retried writes is the reliable way to guarantee a duplicated retry doesn't duplicate the underlying write.
  • Rate limiter fail-open masking abuse. Covered above — decide the failure policy explicitly, don't inherit the default.
  • Auth cache staleness. If the gateway caches JWKS keys or user permission data to avoid a round trip per request, a revoked token or downgraded permission can stay valid at the gateway for the cache TTL. Keep this window short (seconds to low minutes) for anything security-sensitive.
  • Aggregation masking partial failures. If the gateway merges three service responses and one fails, decide explicitly whether to return a partial response (and how the client knows which part is missing) or fail the whole request. Silently returning null for a failed sub-call is the most common version of this bug.
  • Webhook delivery treated as an afterthought. If the gateway also fronts outbound webhook delivery to downstream integration partners, that's an at-least-once delivery problem in its own right and deserves the same retry-and-dead-letter rigor as any other unreliable network call, not a fire-and-forget POST.

Sources: Practical DevSecOps — API Gateway Security Best Practices, AskAntech — API Gateway Architecture Patterns, API7.ai — Rate Limiting Guide, Redis — Rate Limiting Howtos, GeeksforGeeks — API Gateway vs BFF, WunderGraph — Lessons Building BFFs, Suren Raju — Kong Reliability Challenges.

Syslabs' engineering team designs and hardens API gateway and integration layers like this for platform and infrastructure clients.