Back to Blog
SecurityJune 24, 20264 min read

GraphQL security: Preventing Batching Attacks and Resource Exhaustion

GraphQL security hinges on controlling batching attacks. Learn how to prevent resource exhaustion and DoS by enforcing query depth and complexity limits.

GraphQLSecurityAPIDoSWeb DevelopmentWebBackend

During a recent refactor of a client's public-facing API, I noticed our error logs spiking with mysterious timeouts. It turned out that a single client was sending a batch of 500 identical, expensive queries in one HTTP request, effectively bypassing our standard rate limiter.

If you’re running a GraphQL server, you’re likely vulnerable to this unless you’ve explicitly hardened your execution engine. GraphQL’s flexibility is a double-edged sword; it allows clients to request exactly what they need, but it also gives them the power to request everything at once.

Understanding GraphQL Batching Attacks

Most GraphQL servers—whether using Apollo Server, Yoga, or custom implementations—support batching by default. It’s a performance feature: instead of firing ten separate HTTP requests, the client sends an array of queries.

The problem arises when an attacker treats that array as a weapon. If your rate limiter counts total HTTP requests, sending one array of 200 "heavy" queries counts as exactly one request. Your server will happily attempt to resolve all 200 queries concurrently, leading to rapid CPU saturation and memory pressure. This is a classic case of resource exhaustion that often goes unnoticed until the service becomes unresponsive.

We first tried simple request-based rate limiting, but it didn't solve the issue because the attacker just grouped more queries into fewer requests. We realized we needed to analyze the intent of the query before execution.

Defense Strategy: Depth Limiting

The most immediate step for GraphQL security is enforcing query depth limits. Most malicious queries involve deeply nested relationships—like asking for a user, then their posts, then the comments on those posts, then the authors of those comments, and so on.

You can use libraries like graphql-depth-limit to reject queries that exceed a specific nesting level. For most applications, a depth of 5 to 7 is more than enough for legitimate use cases.

JAVASCRIPT
import depthLimit from CE9178">'graphql-depth-limit';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(5)],
});

If a client tries to go deeper, the server returns a validation error immediately without ever touching your database or resolvers. It’s a cheap, effective filter.

Advanced Protection: Query Complexity Analysis

Depth limiting is good, but it doesn't account for "wide" queries. A query might be shallow but request 1,000 fields across 50 different entities. This is where API Security: Preventing Resource Exhaustion with Query Complexity Analysis becomes essential.

By assigning a "cost" to each field in your schema, you can calculate the total weight of a query before it runs.

  1. Assign a default cost of 1 to every field.
  2. Assign higher costs to expensive operations (e.g., searches or complex filters).
  3. Sum the costs of all fields in the incoming document.
  4. Reject the request if the total cost exceeds a threshold (e.g., 500).

When you combine this with batching, you must calculate the sum of the costs across the entire array. If you allow a maximum cost of 500 per request, a batch of 10 queries, each costing 60, should be rejected.

Addressing Circular Query DoS

Circular queries are a specific variant of DoS prevention where a client traverses a graph cycle (e.g., A -> B -> A). While depth limiting catches these, it’s worth noting that your resolvers should always implement data loaders to prevent the N+1 problem.

If you aren't using something like DataLoader in Node.js, even a "simple" query can trigger thousands of database hits. I've seen systems fall over because a recursive query triggered a cascade of database connection pool exhaustion. Remember, GraphQL security: Preventing Improper Authorization in Resolvers is just as critical; if an attacker can force your server to resolve a circular graph, they might also be probing for authorization bypasses.

Implementation Checklist

To harden your API, I recommend this order of operations:

  1. Disable Batching: If your frontend doesn't strictly require it, turn off query batching in your server configuration. It removes the attack vector entirely.
  2. Enforce Depth Limits: Use a validation rule to prevent deeply nested queries.
  3. Implement Complexity Analysis: Define costs for your schema fields and reject requests that exceed your budget.
  4. Persisted Queries: For production environments, move to WordPress GraphQL Persisted Queries: Securing Your API. By whitelisting specific queries on the server, you remove the ability for clients to send arbitrary, malicious payloads.

What I'm Still Watching

I’m still not entirely comfortable relying solely on complexity scores. They are inherently subjective—what if a database index changes or a cache gets evicted? The "cost" of a resolver can fluctuate wildly.

Next time, I'd like to implement a more dynamic "cost" system that monitors actual execution time per resolver in production and feeds that data back into the complexity calculator. It’s a complex piece of engineering, but for high-traffic APIs, it’s probably the only way to stay ahead of resource exhaustion. Keep your limits tight, monitor your execution times, and don't assume your GraphQL server is safe just because it’s behind a standard WAF.

Similar Posts