Why do teams push GraphQL field authorization down into the data-loading layer, and what does it cost?
answer
- Coverage by construction, not by discipline
- Every edge fetches through one place
- The request cache belongs to one viewer
- Filtering changes counts, not just access
- Permission lookups fan out too
basics
~20 sEvery traversal that reaches an object goes through the loader, so a check there covers paths nobody enumerated, including fields added later. It costs a batch cache that must be viewer-scoped, denial becoming absence, and policy lookups needing their own batching.
solid answer
~50 sPer-resolver checks give coverage only as good as the discipline of every future author, and a schema with thousands of fields and dozens of contributing teams loses that race. Pushing the rule into the layer that loads the object — the loader, the repository, or a predicate compiled into the backing query — makes coverage structural: a new edge into `Shipment` inherits the gate because there is no other way to fetch one. Three costs follow. **A per-request batch cache keyed by id must be scoped to one viewer**, or an entity authorized on one path is served unchecked on another. **Filtering unauthorized rows in the query turns denial into absence**, quietly changing list lengths, page sizes and totals rather than producing an error. And **the policy lookup fans out** — one per object — unless it is batched like any other load.
code
pseudocode · 10 lines# one gate every traversal passes through
loader = BatchLoader(scope = REQUEST, viewer = ctx.viewer) { ids ->
rows = shipments.findAll(ids)
allowed = policy.readableShipments(ctx.viewer, ids) # batched, not per-row
rows.filter { it.id in allowed }
}
resolve Carrier.shipments(carrier): loader.loadMany(carrier.shipmentIds)
resolve Leg.shipment(leg): loader.load(leg.shipmentId)
resolve Query.shipment(id): loader.load(id)go deeper
Understand the basic idea: if every path to an object goes through one loading component, putting the check there means new fields are protected without anyone remembering to protect them.
Explain why a request-scoped batch cache is safe and a shared one is not, and what changes for clients when denial becomes an absent row rather than an error.
Demonstrate the operational side: batching the policy lookup so it does not become its own fan-out, keeping aggregates on the same predicate, and testing denial along paths nobody designed.
Argue the organisational case — converting a per-field obligation on many teams into a property of one component a small platform team owns — and name what that still leaves uncovered.
## The argument for push-down Field-level authorization has a coverage problem before it has a correctness problem. The rule you wrote is almost certainly right; the question is whether it runs on every path. In a freight-tracking graph, `Shipment` might be returned by `Query.shipment`, `Carrier.shipments`, `Consignment.shipments`, `Leg.shipment`, a search field, and a subscription payload. Six edges today, and the seventh arrives in a pull request from a team that has never read your policy. Pushing the check into the layer that loads the object inverts that. Whatever edge a resolver came from, it obtains a shipment through the same loader or repository, so the gate is passed by construction. This is the same reasoning behind row-level filtering in a database: enforce where the data is produced, not where it is requested. Two shapes are common, and they behave differently: - **Check after load.** Fetch by id, then compare the loaded object against the viewer and refuse. Denial is explicit and can be reported. - **Filter during load.** Compile the viewer's constraint into the query itself, so unauthorized rows never materialise. Denial is invisible: the row simply is not there. ## Cost one: the batch cache is a viewer-scoped object A batch loader typically memoises by key for the life of a request, so `Shipment:40718` is fetched once no matter how many edges ask for it. That is safe while a request has one viewer — and it is exactly what breaks when a team "optimises" by making loaders process-wide, or when a server reuses a context across subscription events for different subscribers. The rule is blunt: either the cache lives and dies with one viewer's request, or the viewer is part of the cache key. If the check happens *above* the loader while the loader caches raw rows, you also have to accept that the cache holds unauthorized data — fine in principle, dangerous the moment someone returns cached values directly. ## Cost two: absence is not the same product decision as denial Filtering in the query is the strongest coverage you can get, and it changes observable behaviour. A dispatcher who lacks visibility into one carrier's legs does not see an error; they see a shorter list. That interacts with paging — a page requested with `first: 25` can come back with 19 items after filtering, and a naive total count computed without the same predicate will disagree with the list it labels. Whatever you choose, the two enforcement layers must agree: a schema where one path errors and another silently drops rows is confusing to clients and, worse, is a difference an attacker can read. ## Cost three: the policy lookup is its own fan-out Instance-level rules need data — memberships, shares, delegation grants. Checking one object at a time inside a loop over a list means one policy call per object, which is the same fan-out problem the loading layer was built to solve. Policy lookups must be batched exactly like entity loads: collect the ids, resolve the viewer's permissions for the whole set in one call, then filter. A candidate who has actually done this brings it up unprompted, because it is usually the first thing that shows up in latency after push-down ships. ## What push-down does not solve It covers *fetching*, so it is weakest wherever data reaches a field without a fetch. Values already loaded on a parent and returned by a trivial resolver never touch the loader. Aggregates computed in the query — counts, sums of `negotiatedRateCents` — can leak the shape of rows the viewer cannot read unless the same predicate is applied to the aggregate. Fields sourced from a cache, a search index or an event payload have their own path to the data and need their own gate. And a mutation that writes still needs its own decision; a read gate says nothing about whether this viewer may change the row. ## How to decide For a four-person platform team supporting a schema many product teams extend, push-down is the only option that scales, because it converts a per-field human obligation into a property of one component they own. Keep a declarative marker in the schema anyway for the static half, so coverage stays measurable from outside. Then invest in the two things push-down makes possible and nothing else does: a test suite that asserts denial along *unexpected* paths, and metrics on denials per field so a policy change that starts silently emptying lists is visible before a customer reports it.
- What breaks if the per-request batch cache is shared between viewers?An object authorized for the first viewer is handed to the second from cache without the check running again. It is a particularly nasty bug because it depends on request interleaving, so it passes tests and appears under load. Either bind the cache lifetime to a single viewer's request, or make the viewer part of the cache key so two viewers can never share an entry.
- You filter unauthorized rows during load. What else must change?Everything derived from the same rows: counts, totals, aggregate fields and any paging metadata must apply the identical predicate, or the response contradicts itself and the count leaks the number of rows the viewer cannot see. Clients also need to understand that a page may return fewer items than requested, so paging must be driven by the returned page information rather than by assuming a full page.
- Which fields does data-layer enforcement fail to protect?Any field whose value never passes through the loader: values already present on the parent object, aggregates computed inside the backing query, data read from a cache or search index, and payloads pushed from an event stream. Each of those needs its own check at the point it produces data, which is why push-down complements a declared field-level rule rather than replacing it.
Checking badges at the vault door instead of at every corridor: you stop maintaining a list of corridors, and you accept that people now find the vault simply empty rather than being told no.
saying these in an interview costs you the question
- Makes batch loaders process-wide to improve hit rate
- Checks permissions once per object inside a loop
- Assumes filtering rows keeps counts and totals correct
- Believes a read gate also authorizes writes
- Says push-down removes the need for any field-level rule
- Ignores fields whose values never pass through the loader