How can a denied GraphQL field confirm that a record exists, and when does that matter?
answer
- Refusal and absence are different answers
- The error path is itself a disclosure
- Aliases turn one request into many probes
- Ask whether the caller should know it exists
- One convention per object, every path
basics
~20 sIf a forbidden record produces an error entry at that field's path while a non-existent one produces a plain null, the difference answers a question the caller was never authorized to ask. It matters when identifiers are guessable or enumerable.
solid answer
~50 sDenial and absence are different signals, and a caller can read the difference. A GraphQL response separates them in several ways at once: an error entry carrying the field's path versus a null with no error; a shorter list versus an explicit refusal; even timing, since a denial usually follows a successful load while a miss returns early. Probe `shipment(id:)` across a range of identifiers and the pattern maps out which ones are real — an enumeration oracle built from authorized behaviour. The fix is to decide, per field, whether existence itself is sensitive. Where identifiers are guessable, make denial indistinguishable from absence: same null, same shape, no error. Where the caller legitimately already knows the record exists, a distinguishable refusal is better for support and for the client. What you cannot do is mix the two conventions across paths to the same object.
code
json · 7 lines{
"data": { "a": null, "b": null, "c": null },
"errors": [
{ "message": "Forbidden", "path": ["a"] },
{ "message": "Forbidden", "path": ["c"] }
]
}go deeper
Recognise that a refusal and a not-found are different answers, and that returning them differently tells a caller which identifiers are real.
Be able to name the channels that separate the two cases in a GraphQL response — the error entry, its path, list length, and timing — not just the message text.
Show the per-field judgement: when hiding existence is worth the usability cost, when a clear refusal is better, and how you keep one convention across every path to the object.
Frame it as a threat-model decision recorded once per object class, with the enforcement point chosen so that consistency is structural rather than a rule teams must remember.
## Two different nulls A GraphQL response has room for exactly the distinction an attacker wants. When a resolver refuses, the usual convention is a null in `data` at that position plus an entry in `errors` whose `path` names the field. When a record simply does not exist, the usual convention is a null and nothing else. Those two responses are trivially distinguishable, and the difference carries information: *this identifier names a real object you may not see*. ```json { "data": { "shipment": null }, "errors": [ { "message": "Forbidden", "path": ["shipment"] } ] } ``` versus ```json { "data": { "shipment": null } } ``` A caller who walks `SHP-40711` through `SHP-40760` and records which shape came back has just enumerated the shipments that exist without ever being authorized to read one. Whether that is a real problem depends on the field, and that judgement is the interesting part of this question. ## The channels that leak, beyond the error entry The error entry is the obvious one, and it is not the only one. - **The error path.** Even a generic message leaks position: an entry at `["consignment","legs",3,"driver"]` says the third leg exists and has a driver. - **List length.** Silently filtering unauthorized rows from a list is the *safer* convention, but if a caller can compare an unfiltered count against the filtered list, the difference restores the leak. - **Timing.** A denial that fetches the row and then refuses is slower than a miss that returns from an index probe. On a graph that accepts many aliased fields in one document, timing differences are easy to average out over dozens of samples in a single request. - **Mutation feedback.** "You may not update this shipment" versus "no such shipment" leaks exactly the same fact through the write path, and write paths are often overlooked. - **Field suggestions and shape.** A response whose surrounding fields resolved for one identifier and not for another discloses the parent's existence even when the sensitive leaf is nulled. ## Deciding, per field, whether existence is sensitive The useful frame is: *does the caller already legitimately know this thing exists?* When the answer is yes — a customer opening a tracking link, a support agent typing a reference someone read to them — a clear refusal is the better product. It tells the user to ask for access rather than to conclude their reference was wrong, and it makes support tickets resolvable. Hiding existence here buys nothing and costs comprehension. When the answer is no — sequential identifiers, small keyspaces, or a graph where a single document can probe hundreds of identifiers through aliases — existence is part of what you are protecting, and denial must look exactly like absence. That means the same null, no error entry, and ideally similar work performed so timings do not separate the cases. Note that opaque or random identifiers reduce the value of enumeration but are not the control; a leak that requires guessing is still a leak, and identifiers travel. ## Consistency is the hard part The failure that actually ships is not choosing wrongly, it is choosing differently in different places. One path errors, another filters, a third returns a partially resolved parent, and the attacker simply uses whichever path talks. Because a graph reaches the same object several ways, the existence convention has to be a property of the *object*, decided once, and applied by whatever layer enforces the rule — which is much easier when a single loading layer makes the decision than when forty resolvers each make their own. Worth stating plainly in an interview: none of this is in the specification. The specification says a field error may accompany partial data and describes the error path; it says nothing about access control or about what a refusal should look like. Everything above is convention, and the interviewer is testing whether you know that the convention is a choice with a threat model behind it, rather than a default you inherited. (How a refusal is *worded*, and how much internal detail an error carries, is a related but separate concern from whether the refusal is observable at all.)
- Is hiding existence always the safer default?No, and defaulting to it makes systems harder to use. When the caller already has a legitimate reference — a tracking link, a reference number read to a support agent — an explicit refusal tells them to request access instead of doubting the reference. Reserve indistinguishability for fields where the keyspace is guessable or where the mere existence of the record is itself confidential.
- Why do aliases make this leak much more practical to exploit?One document can select the same field many times under different response keys, so a single request probes dozens or hundreds of identifiers and returns one response to compare. That turns an attack that would need rate-limited request-per-guess into a handful of requests, and it also averages out timing noise, which is why per-operation limits on repeated fields matter here as much as any authorization rule.
- How do you keep the choice consistent across a large schema?Make it a property of the object rather than of each resolver, and enforce it where the object is produced, so every edge inherits the same behaviour. Then test it: pick objects the test viewer cannot see and assert the response is byte-comparable to the response for identifiers that do not exist at all, along every path that returns the type.
A receptionist who says "they are not taking visitors" for real staff and "no one by that name" otherwise has published the staff directory without meaning to.
saying these in an interview costs you the question
- Assumes a generic message removes the existence signal
- Forgets the error path discloses position and existence
- Ignores response-time differences between denial and miss
- Treats random identifiers as the fix for enumeration
- Uses different denial conventions on different paths
- Overlooks mutations leaking the same fact