Safeguard
Application Security•

ASVS 5.0 V4.3.1: Limit GraphQL Query Depth and Cost

One nested GraphQL query can pull millions of rows. Use an allowlist, depth limits, amount limits or cost analysis to stop query based denial of service.

Safeguard Security Team
5 min read

ASVS 5.0 V4.3.1 (level 2) requires a query allowlist, depth limiting, amount limiting or query cost analysis, so that expensive nested queries cannot cause denial of service against your GraphQL API or any data layer that accepts expressions.

GraphQL lets the client choose the shape of the request. That freedom is the feature, and also the problem. A tiny text query can ask for a deeply nested graph, and the server will faithfully try to build it.

Why it matters

Consider a schema where an author has posts, a post has comments, and a comment has an author. A query that loops through that cycle ten levels deep is a few hundred bytes, and it can fan out into millions of database lookups. Add aliases and the same field is requested a thousand times in one call. One client can saturate your database with a request that rate limiting by request count never notices, because it is only one request.

Batching makes it worse, since an array of queries in one POST bypasses naive per request limits.

How to implement it

Pick at least one control from the ASVS list, and most teams combine three.

  1. Depth limit. Reject queries nested beyond what your real clients need.
  2. Amount limit. Cap list sizes with a required first argument and a maximum.
  3. Cost analysis. Assign a cost to fields and reject queries over budget.
  4. Allowlist. For first party clients, accept only persisted queries by hash.

With Apollo Server and the graphql-depth-limit package:

import depthLimit from 'graphql-depth-limit';
import { ApolloServer } from '@apollo/server';

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

Enforce page sizes in resolvers:

const MAX_PAGE = 100;
posts: (_, { first = 20 }, ctx) =>
  ctx.db.posts.find({ limit: Math.min(first, MAX_PAGE) }),

Also cap the number of aliases and operations per request, set query timeouts at the data layer, and limit the request body size.

Persisted queries and operational limits

For a first party web or mobile client, the strongest control is the allowlist. Build your queries at compile time, store them on the server keyed by a hash, and accept only a hash from clients in production. Any query text that is not on the list is refused, so an attacker cannot send a hostile shape at all.

import { ApolloServer } from '@apollo/server';
import { readFileSync } from 'node:fs';

const persisted = JSON.parse(readFileSync('./persisted-queries.json', 'utf8'));
// { "sha256hash": "query Orders { ... }" }

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [{
    async requestDidStart() {
      return {
        async didResolveSource({ request }) {
          const hash = request.extensions?.persistedQuery?.sha256Hash;
          if (!hash || !persisted[hash]) throw new Error('query not allowed');
        },
      };
    },
  }],
});

If you serve third parties and cannot use an allowlist, lean on cost analysis and per client budgets. Give each client key a cost budget per minute, not only a request count. Log the cost, depth and duration of every operation, and alert on the top few percent, because the first sign of abuse is usually a slow query in your database dashboard.

Finally, think about the data layer behind the schema. Resolvers that call the database one row at a time turn a modest query into thousands of round trips. Use batching, such as a DataLoader, and set a statement timeout at the database so no single query can run for minutes. These steps also make honest traffic cheaper.

How to test it

Build a deliberately hostile query and see what the server does.

query Deep {
  author(id: 1) { posts { comments { author { posts { comments { author { posts { id } } } } } } } }
}

The server must reject it with a validation error before touching the database. Then test amounts and aliases.

query Alias { a1: users(first: 100000) { id } a2: users(first: 100000) { id } }

Expect a rejection for the page size and for alias count. Watch database CPU and query counts during the test, since the real failure is load, not the response. Add these queries to your CI as a regression suite.

Common mistakes

  • Setting a depth limit but allowing unbounded first values.
  • Counting only top level operations, so aliases and batching slip through.
  • Applying limits in the gateway but not on an internal GraphQL endpoint that is also reachable.
  • Cost values that ignore list multipliers, so a list of lists looks cheap.
  • No timeout at the database, so a slipped query runs for minutes.

Try it yourself

Click through it at your own pace, or open it full screen.

Quick check

Why do aliases matter for GraphQL denial of service?

Aliases let one request ask for the same expensive field many times, so limits per request or per depth miss the real load. Cap alias count or use cost analysis.

Is a depth limit enough on its own?

No. A shallow query can still request huge lists. Combine it with page size limits or cost analysis.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.