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.
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.
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.
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.
Related
- Enforcing Persisted Query Safelists in Apollo Router — the strongest admission control
- Rate Limiting Federated GraphQL Operations — where limits actually apply
- Propagating JWT Claims to Subgraphs with Apollo Router — identity beyond the edge
- Directive Patterns for Cross-Service Authorization — the schema-level half
- Apollo Router Configuration and Deployment — the deployment these settings live in
- Federated GraphQL Operations in Production — parent section