Securing the Federated Gateway

The router is the only component that sees every operation, which makes it both the best place to enforce a security posture and the single point where getting it wrong exposes the whole graph. This guide, part of Federated GraphQL Operations in Production, covers the controls that belong at the gateway — operation admission, cost limits, rate limiting, token handling, and what the router must forward to subgraphs — and, just as importantly, which controls do not belong there.

Prerequisites

Concept Deep-Dive: Four Layers, in Order of Cost

Gateway security is best understood as a sequence of filters, each cheaper than the next and each reducing what the next has to consider. Ordering them correctly is most of the design.

Admission decides whether this operation may run at all. Persisted-operation safelisting is the strongest form: the router looks up an identifier and refuses anything not in the registry, before parsing. It costs a hash lookup and eliminates the entire category of crafted queries.

Cost control decides whether a permitted operation is affordable. Depth limits, alias caps, and parser token limits bound how much work a single document can request. These matter even with safelisting, because a safelisted operation with hostile variables can still be expensive.

Rate limiting decides whether this caller may run it now. It operates on the request rather than the document, and it is the only layer that can respond to volume rather than shape.

Identity and authorization decide what the caller may see. The router validates the token once and, if the schema uses authorization directives, filters the selection before planning — the mechanics of which are covered in directive patterns for cross-service authorization.

Four filters, cheapest first Admission rejects unknown operations before parsing, cost control rejects oversized documents before planning, rate limiting rejects excess volume, and authorization filters the selection before execution. Reject as early as the information allows 1. admission — before parsing Rejects any operation not in the registry. Cost: one hash lookup. 2. cost control — before planning Rejects documents that are too deep, too aliased, or too long. Cost: parsing only. 3. rate limiting — before execution Rejects excess volume from one caller or in total. Cost: a counter. 4. authorization — during planning Filters fields the caller may not see. Cost: claim checks, sometimes a policy call.

Configuration Spec Table

Key Where Example What it stops Notes
persisted_queries.enabled router.yaml enabled: true Ad-hoc operations Requires a published manifest
persisted_queries.safelist.require_id router.yaml require_id: true Full-text operations entirely The strongest single control
limits.max_depth router.yaml max_depth: 12 Deeply nested traversals Set from your deepest real operation
limits.max_aliases router.yaml max_aliases: 30 Alias-multiplication attacks Cheap and frequently omitted
limits.parser_max_tokens router.yaml 15000 Enormous documents Bounds parser work itself
traffic_shaping.router.global_rate_limit router.yaml capacity, interval Volume against the whole graph Per process, not cluster-wide
authentication.router.jwt router.yaml jwks, issuer Forged or expired tokens Validate once, at the edge
headers.all.request.propagate router.yaml named: authorization Subgraphs seeing no identity Nothing forwards by default
csrf.required_headers router.yaml header list Simple cross-site requests Only relevant for cookie auth

Step-by-Step Implementation

Step 1 — Validate identity once, at the edge

# router.yaml
authentication:
  router:
    jwt:
      jwks:
        - url: https://idp.example.com/.well-known/jwks.json
          poll_interval: 5m
      # Reject anything not issued by us before a single resolver runs.
      issuer: https://idp.example.com/
      header_name: authorization
      header_value_prefix: Bearer

Step 2 — Bound what any single document may ask for

limits:
  max_depth: 12            # measure your deepest genuine operation, add headroom
  max_height: 200          # total fields in the document
  max_aliases: 30          # aliasing multiplies work without adding depth
  max_root_fields: 20
  parser_max_tokens: 15000

Step 3 — Rate limit, and know what the limit applies to

traffic_shaping:
  router:
    global_rate_limit:
      capacity: 1000
      interval: 1s
  all:
    timeout: 5s

Step 4 — Forward exactly what subgraphs need, and nothing else

headers:
  all:
    request:
      - propagate: { named: "authorization" }
      - propagate: { matching: "^x-b3-.*$" }
      - insert: { name: "x-caller", value: "router" }

Composition Pipeline Integration

Most of what is above is runtime configuration rather than schema, which creates a governance gap worth closing deliberately: a subgraph team can add a field with real security implications without touching anything the router configuration reviews. Two habits close it.

First, treat authorization directives as part of the schema review. A field gaining or losing @requiresScopes is a security change and should be reviewed as one, which means the check output has to be read rather than merely green.

rover subgraph check my-graph@current --name orders --schema ./schema.graphql

Second, keep the router configuration in version control next to the supergraph and diff it on every release. Limits and rate limits drift upward under pressure — someone raises max_depth to unblock a launch — and without a diff nobody notices that the value stayed raised. The pipeline that publishes schemas is the natural place to also assert that these values have not moved without review; see federated schema validation in CI/CD pipelines for the surrounding workflow.

Performance & Scale Considerations

Every control here is cheap by design, but they are not equally cheap, and the ordering matters more as traffic grows.

Safelisting is nearly free and reduces work, because a rejected operation never reaches the parser. It is the only control that makes the router faster.

Depth and alias limits cost a parse. That is meaningful only under attack, which is exactly when you want the cost bounded.

Rate limiting costs a counter increment, but the semantics deserve attention: the router’s rate limit is per process. With ten replicas, a capacity: 1000 limit admits ten thousand requests per second in aggregate. Teams routinely set a per-process number believing it applies fleet-wide, and discover the difference during an incident.

Authorization is the only control whose cost scales with the response rather than the request. Scope checks are trivial; policy evaluations against an external decision point are a network call, and one per policy per request unless deduplicated.

Relative cost of each control per request Safelisting reduces work overall. Limits cost a parse. Rate limiting costs a counter. External policy evaluation costs a network call and is an order of magnitude more expensive than the others. Cost per request, relative safelist lookup a hash lookup — and it removes parsing entirely depth / alias limits one parse of the document rate limiting a counter, per process — not per cluster external policy call a network round trip, per policy, per request Only the bottom row is worth optimising, and the fix is deduplication per request rather than a faster evaluator. Everything above it is already negligible next to a single subgraph fetch. Which is why "we will add limits later, for performance reasons" is never the real reason.

The Threat Model a Federated Gateway Actually Faces

Generic API security advice under-serves a federated graph, because two of its properties change what is worth defending. The first is that one endpoint accepts arbitrarily shaped requests: a REST API’s attack surface is enumerable from its routes, while a graph’s is the set of all valid documents over its schema. The second is fan-out: a single accepted operation can become dozens of subgraph fetches, so the amplification factor between what an attacker sends and what your infrastructure does is far larger than one.

Those two together produce the threats that matter in practice, and they are not the ones people expect.

Amplification, not intrusion. The realistic attack is a valid, authorized operation that costs a thousand times more to serve than to send. Deeply nested traversals, aliased fields repeated fifty times, and list arguments requesting the maximum page size are all legitimate GraphQL. None of them are stopped by authentication, and all of them are stopped by cost limits.

Introspection as reconnaissance. A public graph that permits introspection hands an attacker a complete map of every type, field and argument. That is not itself a vulnerability — the schema is the API — but it does turn a search problem into a lookup, and for an internal-only graph there is no reason to offer it.

Credential reuse across a wide surface. Because one token reaches the entire graph, a leaked client credential is a leak against every subgraph at once rather than against one service. This is the strongest argument for safelisting: a stolen token that can only run the twelve operations your client actually ships is dramatically less useful than one that can run anything the schema permits.

Error messages as an information channel. Subgraph errors routinely name services, tables, and internal identifiers. Passed through unmodified, they describe your internal topology to anyone willing to send malformed input.

What is conspicuously not on this list is injection in the SQL sense. GraphQL arguments are typed and coerced before a resolver sees them, so the classic string-concatenation vector requires a resolver that goes out of its way to build a query by hand. It happens, but it is a subgraph implementation bug rather than a gateway concern, and no router configuration will help.

Four threats worth configuring against Amplification is addressed by cost limits, reconnaissance by disabling introspection, credential reuse by safelisting, and information leakage by error redaction. Authentication alone addresses none of them. Authentication stops none of these on its own threat what it looks like the control amplification valid but enormous operations depth, height and alias caps reconnaissance introspecting a public endpoint disable introspection externally credential reuse a stolen token running anything persisted-operation safelist information leakage internal detail in error output error redaction at the router Three of the four are configuration changes measured in minutes; only safelisting needs a pipeline.

A Sensible Default Posture

Not every graph needs every control, and the useful question is which posture matches the exposure. Three profiles cover almost everything.

An internal graph, reachable only from your own network and serving first-party clients, needs identity validation, header propagation, and cost limits. Introspection can stay on because it is genuinely useful to the teams using it, and safelisting is usually more process than the risk justifies. The controls here are mostly about containing accidents rather than adversaries: a badly written internal dashboard can hurt a graph just as effectively as an attacker, and depth limits stop both.

A partner graph, reachable by named organisations under contract, adds rate limiting per partner and error redaction. Introspection is a judgement call — partners benefit from it, and the schema is already documented to them. What changes most is observability: you need per-partner attribution on every metric, because “the graph is slow” and “one partner is hammering us” look identical without it.

A public graph should assume every operation is hostile until proven otherwise. Safelisting, tight cost limits, aggressive rate limiting, introspection disabled, and error redaction on. It should also run as its own deployment with its own supergraph, which is exactly what contracts and schema variants for API consumers provides — a smaller schema is a smaller attack surface before any of these controls apply.

The mistake to avoid is applying the public posture to an internal graph. Safelisting an internal graph that dozens of teams query ad hoc turns every exploratory query into a pull request, and the predictable outcome is that someone disables it during an incident and nobody turns it back on. Match the posture to the exposure and each control keeps the meaning it was configured with.

What Does Not Belong at the Gateway

Two things are routinely pushed into router configuration and belong elsewhere.

Per-record authorization. Whether this user may read that order depends on data the router does not have. Attempting it at the gateway means fetching the record to decide whether the caller may fetch the record. It belongs in the subgraph that owns the data.

Business validation. Rejecting an operation because a value is out of range is not a security control, and expressing it in gateway configuration puts domain logic in a file the domain team does not own. Input coercion and resolver-level validation are the right homes.

The test is simple: if the rule needs the data to decide, it is not a gateway rule. If it needs only the request, it is.

Rolling These Controls Out Without Breaking Clients

Every control on this page can break a working client, and three of them will if enabled straight to enforcement. The safe rollout for all of them follows the same shape: observe, warn, then enforce.

Start by turning the control on in a reporting-only mode wherever one exists, and where it does not, set the threshold far above any plausible real value and watch the metric. For limits, that means setting max_depth to something like fifty and recording the distribution of actual depths for a week; the number you want is the observed maximum plus headroom, not a number from a blog post. For safelisting, it means enabling persisted queries without require_id so clients can use them while unregistered operations still work, then watching the proportion of traffic arriving with an identifier climb as client releases roll out.

The middle phase is the one teams skip. Before enforcing, publish what enforcement would have rejected, attributed to a client and an operation name, and give the owners of that traffic a deadline. Enforcement that surprises a team is enforcement that gets rolled back, and a control rolled back once is much harder to reintroduce than one that was never enabled.

Finally, enforce with a documented escape hatch and an alert on its use. A limit with no override becomes an outage during the one legitimate case nobody anticipated; a limit with an unmonitored override becomes decorative. The combination — an override that works and pages someone — keeps both properties.

Keep the values themselves in version control alongside the supergraph, and diff them on every release. Security configuration drifts upward silently under delivery pressure, and the diff is what turns “someone raised the depth limit six months ago” from an archaeology exercise into a line in a pull request.

Failure Modes & Debugging

Every subgraph rejects every request after enabling JWT validation. Root cause: the router now validates tokens but does not forward them, because nothing propagates by default. Fix with an explicit authorization propagation rule.

A legitimate client starts failing with a depth error. Root cause: a limit set from a sample rather than from the deepest real operation. Measure against your persisted-operation manifest before choosing values.

Rate limiting is far more permissive than intended. Root cause: per-process limits multiplied by replica count. Either divide the intended cluster limit by replicas or move rate limiting to a layer with shared state.

Safelisting rejects a client that was working yesterday. Root cause: the client shipped before its manifest was published. Publish the manifest in the build that produces the client, never afterwards.

Frequently Asked Questions

Should subgraphs still enforce authorization if the router does?

Yes, for anything that must hold regardless of how a request arrived. The router’s checks shape the public API; the subgraph’s checks are the guarantee. In a network where subgraphs are only reachable from the router, the second layer is defence in depth. In a flatter network it is the only real control, and the router’s checks are a convenience.

Is query complexity analysis better than depth limits?

It is more precise and considerably more work, because a useful complexity score needs per-field cost estimates that stay accurate as the schema evolves. Depth, height and alias caps capture most of the risk with numbers you can set in an afternoon. Reach for complexity scoring when a specific expensive field justifies it, not as a default.

Where should CSRF protection live?

Only where cookies are used for authentication. A graph authenticated purely by bearer tokens is not vulnerable to the simple-request class of CSRF, and enabling the check adds a header requirement clients must satisfy for no benefit. If cookies are in play, enable it at the router where the whole graph is covered at once.

Can the router hide error details from external clients?

Yes, and it should for a public deployment. Configure error redaction so subgraph error extensions do not reach clients unmodified — internal error messages routinely name services, tables, and internal identifiers. Keep the full detail in traces, where your own team can see it.

Does rate limiting belong at the router or the load balancer?

Both, for different jobs. The load balancer stops volume before it costs you anything and knows nothing about operations. The router can distinguish an expensive operation from a cheap one and apply per-subgraph shaping. A crude limit at the edge plus a semantic one at the router covers far more than either alone.