skip to content

Why can a subgraph's _entities field bypass the authorization it enforces on root fields?

level: seniorimportance: must knowfreq 48%

answer

  1. Root fields are not the only door
  2. The router skips Query entirely
  3. Two entry points, one rule
  4. Key stubs turned into whole objects
  5. Authorize the data, not the door

basics

~20 s

Because the router never calls the subgraph's own root fields for an entity another subgraph referenced. It calls _entities with key representations, so a check written inside a root-field resolver is simply not on that path.

solid answer

~50 s

Every subgraph exposes `_entities(representations: [_Any!]!)` so a router can turn key stubs from one service into full objects in another. That is a second entry point into the same data. A team that put its ownership rule in `Query.donation(id:)` has protected one door: when a client asks for donations through a campaign, the router flattens the list into one `_entities` call, and the reference resolver behind it is usually a bare lookup by key with no check at all. Nothing in the composition specification authorizes `_entities` — it is an ordinary root field. If the subgraph is reachable directly, an attacker writes their own representations and reads entities by key, which is enumeration when keys are sequential. The fix is to authorize the object and field in a layer both paths call, and to test the entity path explicitly.

code

graphql · 9 lines
graphql
query DonationsSubgraph__entities($representations: [_Any!]!) {
  _entities(representations: $representations) {
    ... on Donation {
      amountMinor
      donorNote
      receiptUrl
    }
  }
}

go deeper

for a junior

Recall that a federated service exposes an extra root field, _entities, which the router uses to fetch objects other services referenced by key. It is a second way into the same data.

for a middle

Explain the mechanics: key stubs from one service become representations, the router batches them into one entity fetch, and the reference resolver behind it runs instead of any query root field.

for a senior

Diagnose the bypass and place the fix correctly — in the loader or data layer both paths call, not duplicated in two resolvers — and describe the test that would have caught it before release.

for a principal

Own this as a class of defect rather than a bug: entry points multiply as a graph grows, so make caller-scoped data access the default and require a conformance test every subgraph runs.

## The second front door Every subgraph, by the composition specification, gains two extra fields on its `Query` type: `_service`, which returns the subgraph's own SDL, and `_entities(representations: [_Any!]!): [_Entity]!`, which turns key-shaped stubs into whole objects. `_entities` is how a router resolves any entity that a *different* subgraph referenced. Its argument is a list of representations — small objects carrying the type name and that type's key fields. Now the trap. A subgraph's own root fields are exactly where authorization rules get written, because that is where a request "starts" in the mental model of whoever wrote them. In a charity donations graph, `Query.donation(id:)` in the donations subgraph loads the record and asserts that the caller is either the supporter who gave it or an administrator on the campaign it funded. That assertion runs when someone asks *that subgraph* for a donation. It does not run when the router wants the same object, because the router never calls `Query.donation`. It calls `_entities` — and the reference resolver behind `_entities` is very often a bare lookup by key, written by whoever wired federation up, months after and several files away from the rule it is quietly skipping. ## How the bypass is reached without doing anything exotic The campaigns subgraph owns `Campaign.recentDonations: [Donation]` and returns 24 stubs — key fields only, because it does not own `Donation`. The client's document asks for `amountMinor` and `donorNote` under those donations. The router flattens the list into a single fetch and sends one `_entities` call to the donations subgraph with 24 representations, selecting those two fields inside an inline fragment on `Donation`. The caller asked a perfectly legitimate supergraph question. The donations subgraph answered through a path its own ownership check was never attached to. Nothing was forged, nothing was malformed, and every log line looks ordinary. ## Direct reachability turns a gap into an enumeration If the subgraph can be reached by anything other than the router — another service, a debugging shell, a misconfigured ingress — an attacker does not need the campaigns subgraph at all. They write their own `_entities` document with representations of their choosing and read entities by key. Key fields are ordinary identifiers, frequently sequential, and they are visible to every service that references the type. The composition specification places no authorization on `_entities` whatsoever; it is an ordinary root field on `Query` that happens to be named with a single underscore by convention to keep it out of the client-facing surface. ## Where the check actually belongs The rule of thumb worth saying out loud is **authorize the data, not the door**. Three levels, in increasing robustness: 1. Duplicate the check in the reference resolver. Correct, and it decays — two copies of a rule drift. 2. Push the check into the loader or repository that both paths call, so the entry point stops mattering. 3. Make the data layer caller-scoped, so an unfiltered read is not expressible. Now a new entry point inherits the rule instead of escaping it. Field-level rules deserve the same treatment: if `donorNote` is only for the supporter who wrote it and the campaign's administrators, that rule belongs on the field, and it then holds no matter which parent object carried the caller there. Ownership directives widen the surface further. `@requires` causes a subgraph to be handed sibling fields it does not own, and `@provides` lets another subgraph return fields on your type inline. Any rule that assumed "these fields can only be reached through my root query" is not true in a supergraph. ## Decide the shape of a denial once, for the whole graph A reference resolver that refuses can return `null` in that entity slot, or raise a field error with a path. This is a design choice, not a specified behaviour — but it interacts with the schema: a null landing in a non-null position removes the parent instead of the field. That is precisely why the shape of a denial should be decided once for the whole graph rather than per subgraph. ## Testing the path that has no test Supergraph-level tests almost never exercise the entity path with an unauthorized caller, because from the client's side there is only one graph. Write tests that post an `_entities` document straight at each subgraph, as a caller with no relationship to the data, and assert refusal. In review, the useful question is not "is this field authorized" but "which entry points reach this field" — in a federated graph, the answer is always at least two.

  • If a reference resolver refuses an entity, what does the caller actually see?
    It depends on the deny shape you chose. Returning null leaves an empty slot the router surfaces as a null in that position — quiet redaction, unless the field is non-null, in which case the hole removes the parent instead. Raising a field error with a path is louder and easier to audit. Neither is specified, which is exactly why the convention must be decided once for the whole graph rather than per subgraph.
  • Why are entity key fields effectively public identifiers inside a supergraph?
    Because every subgraph that references the type handles them, and every entity fetch carries them in representations. They are ordinary schema fields, not secrets. If they are sequential integers and the subgraph is reachable directly, someone who can send `_entities` documents can walk the entity space by key — which turns a missing authorization check into bulk extraction rather than a single leak.
  • How would you test that the entity path is authorized?
    Post an `_entities` document straight at the subgraph, with hand-written representations for records the test caller has no relationship to, and assert refusal. Supergraph-level tests will not catch this, because from the client side there is only one graph and the router only ever reaches entities it already found legitimately. Make that test a per-subgraph conformance check in each team's own pipeline.

You fitted a lock and an ID check to the front door, and the loading bay round the back opens on a shouted order number. _entities is the loading bay: same warehouse, same stock, different door.

saying these in an interview costs you the question

  • Assumes a router always enters a subgraph through its root fields
  • Puts every ownership check in root-field resolvers only
  • Believes _entities is privileged or somehow internal-only
  • Thinks entity keys are secret because clients rarely see them
  • Tests authorization only through the supergraph, never a subgraph

context