GraphQLDenial of Service

GraphQL Denial of Service: Deeply Nested and Aliased Queries

GraphQL lets the client decide how much work the server does. Without depth limits, cost analysis and batching caps, a single small request can amplify into enormous server load. Here is how nesting, aliasing and batching turn one query into a denial of service, and how to bound it.

SelfSec Team4 min read
Part 3 of 4from theGraphQLseries
{user{posts{author{posts{id}}}}}
On this page

In a REST API the server decides how much data each endpoint returns. In GraphQL the client decides: it describes the shape of the response, and the server resolves whatever was asked for. That inversion is the whole point of GraphQL, and it is also the root of a denial-of-service class that does not exist in fixed endpoints. A request measured in bytes can demand work measured in CPU-seconds and database round-trips.

The dangerous part is amplification. Because types reference each other and a single field can be requested many times under different aliases, the cost of a query is not bounded by its size. A few hundred characters can fan out into millions of resolver invocations, and a server that happily parses the request will happily try to execute it.

How it happens

The amplification comes from three properties of GraphQL, all of them by design. The first is cyclic relationships. When two types reference each other, a query can walk the cycle to arbitrary depth:

{
  user(id: 1) {
    posts {
      author {
        posts {
          author {
            posts { id }
          }
        }
      }
    }
  }
}

Each level multiplies the resolver work below it. The second is aliasing. GraphQL lets the same field be requested repeatedly under different names in one request, so an expensive operation can be invoked hundreds of times without repeating a single argument by hand:

{
  a1: search(query: "*") { id }
  a2: search(query: "*") { id }
  a3: search(query: "*") { id }
}

The third is batching. Many servers accept an array of operations in a single HTTP request, so even per-request rate limits can be bypassed by packing many queries into one POST. Combine the three and the attacker controls the multiplier on every axis the server cares about.

A concrete attack

The attacker probes a cyclic relationship and then deepens it. With User and Post referencing each other, each added level roughly multiplies the number of resolver calls, so a compact query forces an enormous traversal:

{ user(id:1){ posts{ author{ posts{ author{ posts{ author{ posts{ id }}}}}}}} }

If that is bounded, aliasing offers a flatter but equally brutal path — one expensive field, duplicated hundreds of times in a single document:

{
  a001: allReports(filter: "") { rows }
  a002: allReports(filter: "") { rows }
  a003: allReports(filter: "") { rows }
}

Each alias runs the full allReports resolver. Three hundred aliases run it three hundred times from one request. Batching then stacks whole documents on top, slipping past naive per-request throttling:

[
  {"query":"{ a1: allReports(filter:\"\"){ rows } a2: allReports(filter:\"\"){ rows } }"},
  {"query":"{ a1: allReports(filter:\"\"){ rows } a2: allReports(filter:\"\"){ rows } }"}
]

The size of a GraphQL request tells you nothing about its cost. Validating that a query parses is not the same as deciding the server can afford to run it.

Why it matters

This is a low-effort, high-impact attack. There is no authentication bypass and no data to exfiltrate — the goal is to exhaust CPU, memory, connection pools and downstream database capacity until legitimate traffic times out. A single unauthenticated client can degrade or topple an endpoint that looks perfectly healthy under normal use, and because each malicious request is small, it is easy to send a lot of them.

The fix: bound depth, cost and batching

A GraphQL endpoint must reject a query as too expensive before it executes, not discover the cost while resolving. That means analyzing depth and complexity at validation time, and capping the multipliers an attacker can reach for.

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(7),
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10,
    }),
  ],
  allowBatchedHttpRequests: false,
});

The essential controls:

  • Enforce a query-depth limit so a cyclic relationship cannot be walked to arbitrary depth.
  • Run complexity or cost analysis with a budget, assigning each field a weight and rejecting any query — including alias-duplicated ones — whose total exceeds the budget at validation time.
  • Cap pagination so list fields require bounded first/limit arguments rather than returning unbounded result sets.
  • Rate-limit the endpoint and disable or cap batching, so an attacker cannot multiply load by packing many operations into a single request.

How SelfSec finds it

SelfSec detects GraphQL endpoints during the crawl, then introspects the schema to find cyclic type relationships and expensive list fields it can target. It sends measured probes — incrementally deeper nested queries and alias-duplicated fields — and keys findings on the server's response: a query that should have been rejected but instead returns successfully, or whose latency climbs sharply with depth, indicates a missing depth or complexity limit. It also checks whether batched arrays of operations are accepted. The whole scan runs locally on 127.0.0.1, your schema and scan data stay on your machine, and each finding ships with the exact request to reproduce it.

SelfSec is intended strictly for authorized security testing of systems you own or are explicitly permitted to assess.

Do both things about GraphQL

SelfSec covers this class from both sides — the scanner confirms it in your own app, the firewall blocks it in front of your origin while the fix ships.

The GraphQL series

  1. 01OverviewGraphQL API Security: Introspection, Deep Queries and the Authorization Gap4 min
  2. 02Broken AuthorizationBroken Object-Level Authorization in GraphQL Resolvers5 min
  3. 03Denial of ServiceGraphQL Denial of Service: Deeply Nested and Aliased QueriesReading
  4. 04IntrospectionGraphQL Introspection: Mapping a Hidden Schema in One Query4 min

Related reading