Resolver-level GraphQL security isn't enough on its own

“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 for cost and depth abuse, the resolver is already too late.
This post is about why your first line of GraphQL defense shouldn’t rely on your application alone, 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 a tool that only inspects the HTTP envelope, that request looks much like a harmless one. Same method, same URL, same headers. A WAF or CDN that doesn’t parse GraphQL has little in the envelope to act on, because the intent, and the danger, sits 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 very large resolver and database workloads. 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 exposure. GraphQL ships with introspection, a query that returns the entire schema. It’s useful in development; left open in production, it can hand an attacker a convenient map of types, fields, and mutations to probe next. Whether to restrict it in production is a deliberate decision, not a given.
Broken field-level authorization. This is the quiet one. The query is cheap, well formed, and entirely legitimate in shape. It just asks for a field the viewer shouldn’t see. Authorization in GraphQL is per field and per context, and most frameworks won’t get it right for you by default. It’s a common source of real GraphQL data exposure.
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 first line
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, can drift across teams and services. The same rule ends up in many places in slightly different shapes, and the gaps are easy to miss. Security that depends on every resolver author remembering the same thing tends to grow 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 removes much of the arbitrary-query surface in one move, though it doesn’t replace authorization, input validation, or abuse limits. 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” can turn into weeks or months 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 part of the execution cost may already have 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 spares your database that request’s work. 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 properties worth insisting on in production:
- 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 limits 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.
- Schema-declared field policies and introspection controls 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.
GraphPilot covers part of this at the edge: depth, size and selection limits, an allowlist of registered operations and blocked introspection, all checked before a request reaches the origin. Cost budgets and field authorization stay in your API. Glider (Free) needs no credit card, so you can start for free and put it in front of your own API. 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?
- For cost and abuse controls, resolver checks run after the request has already reached your origin and paid the cost of parsing, planning, and partial execution, so a query designed to overload you may have done much of its damage by the time a resolver rejects it. Resolver logic also tends to live scattered across services and fields, so the same rule gets reimplemented inconsistently. The boundary you can defend first is in front of the origin, where a query is inspected and rejected before execution starts. Deep business authorization still belongs in your application.
- 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 if they can inspect the GraphQL body. 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 the same for a harmless query and a malicious one, so envelope-only tools that don't parse the query have limited visibility into GraphQL-specific attacks; a layer that does parse it can apply meaningful controls.
- 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.