Resolver-level GraphQL security is already too late

“We added a depth limit in the resolver, so we’re covered.” It’s one of the first things a team reaches for the moment they realize a GraphQL endpoint accepts arbitrarily shaped queries. It feels like the responsible move, and it’s not wrong, exactly. It’s just placed at the wrong layer, and that placement is the whole problem. By the time a resolver runs, the request has already crossed your perimeter, been parsed, been planned, and started touching your data. The check fires, but the expensive part already happened. As a first line of defense, the resolver is already too late.
This post is about why your first line of GraphQL defense can’t live inside your application, what does still belong there, and what changes when the boundary moves to the edge, in front of the origin.
GraphQL breaks the assumptions perimeter security is built on
Traditional API security leans heavily on the HTTP envelope. A WAF or a CDN looks at the method, the URL, the headers, and makes a decision: this path is rate limited, that one is blocked, this method isn’t allowed here. The shape of the request tells you a lot about its intent.
GraphQL throws that away. You have one endpoint, usually /graphql, and almost everything goes through POST with the real request sitting in the body:
POST /graphql
Content-Type: application/json
{
"query": "
{
user(id: 1) {
friends(first: 100) {
friends(first: 100) {
friends(first: 100) {
name
}
}
}
}
}
"
}
To anything that doesn’t parse GraphQL, that request is indistinguishable from a harmless one. Same method, same URL, same headers. A WAF sees a POST to /graphql and shrugs. The intent, and the danger, is entirely in the body. So just like with caching, before you can secure anything you have to teach the boundary to look inside the request. And the moment you accept that, the natural question is where that inspection should happen.
The attacks that actually matter
GraphQL’s flexibility is the product. It’s also the attack surface. A handful of attack classes show up again and again, and in every case the query body is what reveals them.
Complexity and depth denial-of-service. GraphQL lets a client ask for deeply nested, recursive relationships in a single query. user → friends → friends → friends and so on. Each level multiplies the work. A short, innocent-looking query can expand into millions of resolver calls and a database meltdown. There’s no oversized payload to catch; the request is tiny. The cost is in how it unfolds.
Aliasing and batching abuse. GraphQL aliases let a client request the same field many times under different names in one operation. Combine that with query batching and an attacker can pack hundreds of attempts into a single HTTP request: brute-forcing a coupon or gift-card code through an aliased lookup, enumerating records by guessing IDs, or amplifying one expensive query into hundreds. Per-request rate limiting counts that as one request and waves it through.
Introspection leakage. GraphQL ships with introspection, a query that returns the entire schema. Wonderful in development, a gift to an attacker in production: every type, every field, every mutation, handed over as a map of where to probe next.
Broken field-level authorization. This is the quiet one. The query is cheap, well formed, and perfectly legitimate in shape. It just asks for a field the viewer shouldn’t see. Authorization in GraphQL is per field and per context, and there is no framework default that gets it right for you. It’s where most real GraphQL data exposure actually lives.
Notice what these have in common: none of them is visible from the outside, and none of them is a malformed request. They’re valid GraphQL. You can only catch them by understanding the query.
Why resolvers are the wrong place to catch them
So you reach for resolver code, because that’s where you understand the query best. Here’s why it disappoints.
It’s too late. A resolver runs during execution. To reach it, the request already crossed your network boundary, got parsed, got planned, and in many cases already started resolving sibling fields. A depth limit enforced in a resolver is a smoke detector that goes off after the fire reaches the kitchen. For a complexity DoS, the work you were trying to prevent is the work that got you to the resolver in the first place.
It’s too scattered. Authorization and limits implemented field by field, service by service, drift. One team checks a permission, another forgets, a third checks a slightly different one. The same rule exists in twelve places in twelve shapes, and the gap is wherever someone was in a hurry. Security that depends on every resolver author remembering the same thing is security with a long tail of holes.
It’s not centrally observable. When the defense is smeared across the resolver graph, there’s no single place that can answer “how many queries did we reject in the last hour, and why.” You can’t tune, alert on, or audit a policy that doesn’t exist as a policy anywhere.
And underneath all three sits the genuinely hard part: to reject a query safely, you have to estimate what it will cost before you run it. That’s not a resolver’s job. By the time a resolver could weigh in, you’ve already started paying.
Where it gets nasty
This is the part that doesn’t fit on a feature slide:
- Static cost vs. real cost. You estimate a query’s cost by walking its shape and assigning weights, before execution. But the true cost depends on data: how many friends that user actually has, how big that list really is. Estimate too loosely and you wave through expensive queries; too strictly and you reject legitimate ones. Tuning that boundary is ongoing work, not a constant.
- Rate limiting by cost, not count. “100 requests per minute” is meaningless when one request can be a thousand times heavier than another. Useful GraphQL rate limiting is budget-based: you spend from a cost allowance, and a heavy query spends more. That means you need the cost estimate and a shared, fast accounting of spend.
- Authorization that’s schema-aware. Field-level auth has to understand types, interfaces, and how a field is reached. The same field may be allowed via one path and not another. This is real design, and parts of it (deep business rules) genuinely do belong in your application. The point isn’t that the edge does all auth; it’s that the edge enforces the structural, declarative parts consistently so resolvers aren’t the only thing standing between a query and your data.
- Persisted operations as an allowlist. One strong mitigation is to stop accepting arbitrary queries at all: register known operations ahead of time and reject anything not on the list. It closes most of the surface above in one move. It also constrains your clients and needs a deploy-time workflow, so it’s a decision, not a free switch. (It pairs neatly with persisted queries on the performance side.)
None of this is insurmountable. Together, it’s why “we’ll just validate queries in our gateway” turns into a quarter of work.
Why the edge is the right layer
Put all of this in front of the origin and the picture gets simpler in exactly the ways that matter.
in a resolver it's caught only after the cost has been paid.
It’s early. The query is parsed and priced before the origin does any work. A rejected query costs the edge a cheap inspection and costs your database nothing. That’s the difference between absorbing an attack and forwarding it to the thing you most need to protect.
It’s one place. Depth and complexity budgets, cost-based rate limits, introspection control, and the structural parts of authorization are declared once, in front of every service, instead of reimplemented in each. One policy, consistently applied, is something you can actually reason about.
It’s observable. A single chokepoint is where you can count rejections, alert on spikes, and audit what your policy did. You can’t tune a defense you can’t see.
The honest tradeoff: this logic now runs on the hot path of every request, so it has to be cheap. Spend too much per request inspecting it and you’ve traded an availability problem for a latency one. Getting that balance right, fast inspection that doesn’t become its own bottleneck, is exactly the engineering that makes edge security hard to do well.
What good looks like
Whether you build this or buy it, a serious GraphQL security layer has a few non-negotiables worth insisting on:
- Schema-driven policy. Limits, scopes, and field rules declared alongside the schema, so they’re reviewable, versioned, and diffable, not buried in scattered code.
- Depth and complexity budgets enforced before execution. A query is priced and accepted or rejected up front, not discovered to be expensive halfway through running.
- Cost-based rate limiting. Spend from a budget, where a heavy query costs more, instead of counting all requests as equal.
- Introspection control and field-level authorization applied by default, so production doesn’t hand out its own map and personalized fields aren’t exposed by accident.
- Observability for what you blocked. Rejected queries, reasons, and rates, visible. “It feels secure” is not a metric.
Closing
A depth limit in a resolver isn’t wrong, it’s just standing in the wrong room. GraphQL’s dangerous requests look identical from the outside and only reveal themselves inside the query, and by the time a resolver reads that query, the request is already past the boundary you wanted to defend. The controls that actually hold, cost estimation, budget-based limits, introspection control, consistent field authorization, want to live in front of the origin, declared once and watched in one place.
That’s part of what we’re building GraphPilot to do. It’s not open yet; if you run GraphQL in production and want early access, or want to shape it as a design partner, you can join the waitlist. We’d genuinely rather hear which of these attacks is keeping you up than guess, so if you want to compare notes, jump into the comments wherever you found this or contact us.
Frequently asked questions
- Why isn't resolver-level GraphQL security enough?
- Resolver checks run after the request has already reached your origin and paid the cost of parsing, planning, and partial execution. A query designed to overload you has done its damage by the time a resolver decides to reject it. Resolver logic also lives scattered across every service and field, so the same rule gets reimplemented inconsistently. The boundary you can actually defend is in front of the origin, where a query is inspected and rejected before any execution starts.
- What are the most important GraphQL attacks to defend against?
- Complexity and depth denial-of-service (deeply nested or cyclic queries that explode into huge amounts of work), aliasing and batching abuse (many operations smuggled into one request to bypass rate limits), introspection leakage (using the schema to map your attack surface), and broken field-level authorization (fields that return data the viewer should not see). The query body, not the HTTP envelope, is what exposes them: the first three are decided entirely by the query, and broken field-level authorization depends on identity, tenant, and business rules, but the query body still reveals which fields and shapes need a policy decision.
- Can a WAF or normal CDN protect a GraphQL API?
- Only weakly. A WAF or CDN keys on method, URL, and headers, but GraphQL sends almost everything as POST to a single endpoint with the real intent in the body. The envelope looks identical for a harmless query and a malicious one, so perimeter tools that don't parse GraphQL are mostly blind to GraphQL-specific attacks.
- What is query cost analysis in GraphQL?
- Query cost analysis estimates how expensive a query will be before it runs, by walking the parsed query and assigning weights to fields, nesting, and list multipliers. The estimate lets you reject a query that exceeds a budget, and lets you rate limit by cost rather than by request count, so one heavy request counts for more than many cheap ones.