Exposing GraphQL Through a REST Gateway

Putting a GraphQL service behind an API gateway breaks most of what the gateway does well. Every request is a POST to one path, so path-based routing and per-endpoint quotas collapse into a single bucket; the operation is in the body, so nothing can be decided without parsing it; and one request can cost anything from a microsecond to a minute. This page covers what to do at the gateway, what to push into a GraphQL-aware layer, and how to expose a REST surface over a GraphQL backend when the callers need one.

Prerequisite concepts

This builds on protocol translation patterns for the general transcoding model and on rate limiting and throttling strategies, because the quota problem is where GraphQL and gateways disagree most.

Why the usual controls stop working

Every gateway control keys off something GraphQL does not vary A REST surface spreads across many paths and methods, so routing, quotas, caching and authorisation can all be expressed per endpoint. A GraphQL surface is one path and one method, so all of those controls collapse into a single bucket unless something parses the request body. REST — the gateway has something to key on GET /v2/orders POST /v2/orders GET /v2/customers DELETE /v2/sessions separate routes, separate quotas, separate cache keys, separate authorisation rules — all for free GraphQL — one route for everything POST /graphql — read, write, cheap, expensive: all of it Every control that keyed on path or method now keys on nothing. Cost also stops being bounded: one request may resolve three fields or three hundred thousand, and the gateway cannot tell which without parsing the body.

What belongs at the gateway anyway

Four controls still work unchanged, and they are worth applying before anything GraphQL-aware.

One request is not a unit of anything A query for a single field resolves in under a millisecond. A moderately nested query fans out to several services. A deeply nested query over a list of lists can occupy a resolver for seconds. All three count as exactly one request against a rate limit. cost of one request against a limit that counts requests { order(id) { total } } 1 resolver — counts as 1 order + customer + items ~40 resolvers — counts as 1 nested lists, 4 levels deep tens of thousands of resolvers — still counts as 1 This is why depth and complexity limits are not optional extras: without them a single well-formed request can consume more backend work than the entire rest of the traffic on that route.
# Envoy 1.32+ — the controls that still apply to an opaque POST
route:
  match: { path: "/graphql", headers: [{ name: ":method", string_match: { exact: "POST" } }] }
  route:
    cluster: graphql_upstream
    timeout: 10s                      # bound the worst case, whatever it is
  typed_per_filter_config:
    envoy.filters.http.buffer:
      "@type": type.googleapis.com/envoy.extensions.filters.http.buffer.v3.BufferPerRoute
      buffer:
        max_request_bytes: 65536      # a 4 MB query document is not a legitimate query

Authentication, a request-size cap, a timeout and a connection-level rate limit are all meaningful without understanding the query. Everything beyond that — cost limits, depth limits, per-operation quotas, persisted queries — requires parsing, and belongs in a GraphQL gateway or in the service itself rather than in a generic proxy.

The one control that changes the picture entirely is persisted queries: clients send an identifier instead of a query document, the server holds the allowed set, and suddenly the gateway can route, cache and meter on that identifier like an ordinary endpoint. If you control the clients, this converts the problem back into one your existing infrastructure already solves.

Exposing REST over GraphQL

The reverse direction — REST-shaped endpoints backed by a GraphQL service — is a facade the gateway can build, and it is how most teams serve partners who do not want to learn a schema.

A REST facade over a persisted operation A GET on a REST path is rewritten into a POST carrying a persisted operation identifier, with the path parameter bound to a query variable. The caller sees an ordinary resource endpoint that can be cached and metered per path, while the backend continues to serve GraphQL. GET /v2/orders/8891 cacheable, meterable gateway facade path param → variable route → persisted operation id POST /graphql id + variables, no document The facade is a contract in its own right: it needs versioning, deprecation and a schema, exactly like any other API. Treat it as one rather than as a thin adapter, or it will drift from the GraphQL schema behind it. Response shaping belongs in the persisted operation, not in a gateway transformation — keep one place to change.

Decision matrix

Control Where it belongs
Authentication and token validation gateway
Request size cap and timeout gateway
Connection and request-rate limits gateway
Query depth and complexity limits GraphQL layer
Per-operation quotas gateway, if persisted queries are used
Response caching GraphQL layer, or gateway with persisted queries
Field-level authorisation the service
REST facade for partners gateway

Gotchas and failure signals

A single request rate limit on /graphql is not a quota, because request cost varies by orders of magnitude. Either adopt persisted queries and meter by operation, or accept that the limit only protects against volume, not against expense.

Errors come back as 200. GraphQL reports failures in the response body, so every gateway metric based on status codes reports perfect health during an outage. Either parse the body for an errors array or rely on upstream metrics rather than gateway ones.

Caching a GraphQL POST is unsafe by default — the body is the cache key and few caches handle that. Persisted queries or a GET-with-query-id convention are the practical ways to make caching possible at all.

Introspection enabled in production hands an attacker a complete map of the schema. Disable it, or restrict it to authenticated internal callers.

The limit that actually protects the backend is a cost budget assigned per caller and decremented by the estimated complexity of each query before it runs. Estimating cost statically from the document — counting fields, multiplying by list sizes declared in arguments — is imperfect but catches the pathological cases, and it fails in the safe direction because an over-estimate rejects a query rather than admitting one that will hurt.

Validation

  • Request size capped and an overall timeout set on the GraphQL route
  • Cost or depth limiting enforced somewhere, and it is documented where
  • Gateway alerting does not rely solely on status codes for this route
  • Schema introspection disabled or restricted in production
  • Persisted queries used wherever the clients are ones you control
  • Any REST facade has its own version and deprecation policy
  • A slow-query log exists on the GraphQL service and is reviewed, because the gateway cannot tell you which operations are expensive and the service is the only component in the path that can
  • Someone owns the persisted-query set, including how a new operation is added to it and how an unused one is retired from it

FAQ

Why does rate limiting a GraphQL endpoint not work?

Because every request is a POST to the same path, so a request-count limit treats a three-field query and a three-hundred-thousand-field query identically. The limit protects against volume and not against expense. Either adopt persisted queries so the gateway can meter per operation, or move cost-based limiting into a GraphQL-aware layer that can parse the document.

What can the gateway still enforce?

Authentication and token validation, a request body size cap, an overall timeout, and connection or request-rate limits. All four are meaningful without understanding the query. Anything that depends on what the query asks for — depth, complexity, per-operation quotas, field authorisation — requires parsing and belongs elsewhere.

How do persisted queries change things?

They convert the problem back into one the gateway already solves. Clients send an operation identifier instead of a document, the server holds the allowed set, and the gateway can then route, cache and meter on that identifier exactly as it would on a path. If you control the clients, this is the single highest-value change available.

Why do my error dashboards look healthy during a GraphQL outage?

GraphQL returns errors inside a 200 response body, so every gateway metric keyed on status code reports success. Either inspect the body for an errors array before recording the outcome, or base alerting on upstream service metrics rather than on gateway status codes for that route.


Parent: Protocol Translation Patterns