Where in GraphQL request handling do content-type checks, depth caps and deadlines each belong?
answer
- Ask what each phase actually knows
- Bytes, then document, then data
- Cheapest rejection first
- Variable values arrive with execution
- Nothing before execution can time anything
basics
~20 sEach control attaches at the earliest phase that has the information it needs. Content-type and body-size checks run before parsing; depth, field-count and cost caps run as validation rules over the parsed document; deadlines and per-object authorization can only run during execution.
solid answer
~50 sThe placement rule is: the earliest phase that has the information the control needs, because every later phase has already paid for the earlier ones. Before parsing, the server holds bytes and headers only - so that is where a body-size cap, a required `application/json` content type and coarse rate limiting go, and where a persisted-document lookup can replace attacker text entirely. Once parsed and checked against the schema, the server holds a document and a schema but no data and no variable *values* - so depth, breadth, alias-count, static cost and a refusal of `__schema` all belong at validation, where they cost microseconds and reject before a resolver runs. Only during execution do variable values get coerced, resolvers run and objects exist - so deadlines, per-object authorization and backend concurrency caps have nowhere earlier to live. Put a control too early and it lacks information and over-rejects; too late and the work is already done.
code
graphql · 13 linesquery SeatMap($count: Int = 50) {
venue(id: "v-7813") {
events(first: 12) {
tiers {
seats(first: $count) {
id
status
holds { expiresAt }
}
}
}
}
}go deeper
Learn the three buckets and one example of each: content type and body size before parsing, depth and cost at validation, timeouts and object checks during execution.
Be ready to justify each placement by the information available at that phase, including that variable values are not visible to a validation rule.
Show the cost argument: each phase is more expensive than the last, so a control placed one phase late has already paid for the work it was meant to prevent.
Own where the boundary sits between the edge infrastructure and the service, and be able to say which controls can never be pushed outward without duplicating the schema.
### Placement follows information, and information arrives in stages A GraphQL request passes through phases, and each phase knows strictly more than the one before it. That monotonic growth of knowledge is the entire basis for deciding where a control belongs. **Before parsing**, the server holds an HTTP request: a method, headers, a source address, credentials and a number of bytes. It does not know whether those bytes are even valid GraphQL. Controls that need nothing more than this belong here, and they are the cheapest you will ever run: a maximum body size, a required content type of `application/json`, TLS and origin checks, authentication of the caller, and coarse per-caller request rate limiting. This is also the only place a persisted-document allowlist can do its real work - if the body carries an identifier that maps to a pre-registered document, the server never parses attacker-supplied text at all. Note what is *impossible* here. Nothing in front of the parser can see a selection set. A component that only reads headers and counts bytes cannot enforce a depth cap, however much you would like to push that work outward. **Parsing** turns text into a document. Its own protective knob is small but real - a cap on tokens or nesting inside the parser itself - and it matters because a document large enough to hurt the parser must be stopped by size *before* parsing, not by a rule that runs after. **Validation** is where most abuse control lives, and it is worth being precise about what validation can see. It is a function of the document and the schema. It has every selected field, its arguments as written, every fragment, and the type of everything. It does not have data, because no resolver has run. And - the detail interviewers probe - it does not have variable *values*: coercing the caller's `variables` map is part of executing a request, not part of validating a document. So a static cost rule reading `seats(first: 250)` sees the literal `250`, while the same rule reading `seats(first: $count)` sees only the variable's declared type and default. Servers that want the real number must reach outside pure validation and read the variables map, which is a deliberate, common extension rather than something the specification's validation phase provides. With document and schema in hand, validation can enforce: maximum selection depth, maximum total field count, maximum aliases of one field, a static cost score, a limit on operations in one document, and a refusal of any document touching `__schema` or `__type`. Every one of these rejects before a single resolver runs, which is why they are worth so much - the rejection costs a parse and a tree walk, not a database round trip. **Execution** is where the world becomes concrete. Variable values are coerced, resolvers run, objects come back from your data stores. Only here can you enforce a wall-clock deadline (there is nothing to time before this), authorize a specific object (it did not exist before), cap concurrent backend calls, or bound the fan-out one field triggers. ### A worked example on a ticketing graph Suppose an endpoint fronting a venue and ticketing schema is asked for a seat map: A `POST` arrives with `text/plain`. It is rejected at the HTTP layer, before parsing, because that content type is one a cross-site form can send. Cost so far: reading a header. The next request is well-formed JSON, 41 KB of document text. The body-size cap is 64 KB, so it proceeds. It selects a venue, its events, each event's tiers, each tier's seats, each seat's holds and each hold's order - depth 7, and with a `first: 500` on seats a static cost of about 6,400 points against a 5,000-point budget. Validation rejects it. Cost so far: a parse and a walk, well under a millisecond, and zero queries against the seat inventory. A third request passes every static check and asks for a single order's buyer email. Nothing before execution can decide whether *this* caller may read *that* order, because the order has not been loaded. So the check runs on the resolved object, and a 900 ms deadline bounds the whole thing in case the inventory store is slow. ### The two failure modes of bad placement Too early: the control lacks information and must be conservative. A pre-parse component asked to guess whether a body is expensive can only guess from its size, so it rejects large-but-cheap documents and admits small-but-devastating ones. Too late: the control is correct but has already paid. Authorizing only after every resolver has run, or timing an operation only once it returns, means the abusive request consumed exactly the resources you were defending. A good answer names the rule - earliest phase with sufficient information, cheapest checks first - and then demonstrates it on two or three controls rather than reciting a list.
- Why can a static cost rule read `first: 12` but not the value behind `first: $count`?Because validation is defined over the document and the schema, and the caller's `variables` map is coerced as part of executing the request, not as part of validating it. A validation rule therefore sees the variable's declared type and any default, not the value that will be used. Implementations that want the real number pass the variables map into their cost rule explicitly - a useful extension, but it is the server reaching past what the validation phase provides.
- A team wants to enforce the depth cap in the reverse proxy in front of the service. What do you tell them?That it cannot work as stated: depth is a property of a parsed selection set, and a component that only reads headers and bytes has no document. To enforce depth there, the proxy would have to parse GraphQL and hold the schema - at which point it is a GraphQL server with a second copy of the schema to keep in sync. Push size, rate and content-type outward; keep document-shaped limits in the service.
- Which control belongs before parsing but is routinely placed after it?The persisted-document lookup. If the server parses the incoming text first and only then checks whether the document is registered, it has already spent parse time on attacker-supplied input and gained nothing. Done properly, an identifier in the request selects a document the server already holds - typically already parsed and validated - so the untrusted text never reaches the parser.
It is the sequence at a venue door: the bag-size rule works on the pavement, the ticket check needs the ticket in hand, and whether you may sit in that particular seat can only be settled once someone walks you to it.
saying these in an interview costs you the question
- Putting a depth cap in a component that never parses the document
- Claiming validation can see variable values
- Authorizing objects at validation time before anything is loaded
- Enforcing a body-size limit only after parsing
- Believing a per-operation timeout can start before execution
- Treating the phases as interchangeable places to put any check