GraphQL: Depth, Complexity and Introspection

A query language that lets clients compose their own requests also lets them compose an expensive one.

GraphQL's strength is that the client asks for exactly what it needs. Its security problem is the same sentence: the client composes the query, and the cost is decided at request time rather than at design time.

Depth attacks

Circular relationships let a query nest indefinitely. user → posts → author → posts → author repeated twenty times is a small document that produces an enormous result and a very large number of database calls.

Fix: a maximum depth, typically 7 to 10.

Complexity attacks

Depth alone is not enough. A shallow query requesting a thousand items, each with a thousand nested items, is depth 2 and a million rows. Assign a cost to each field, multiply by pagination arguments, and reject queries above a budget.

Batching and aliases

One HTTP request can carry many operations, and aliases let the same expensive field be requested a hundred times in one document. A rate limit counting requests counts that as one. Limit aliases and batch size, and rate-limit on complexity rather than on request count.

Introspection

Introspection publishes your entire schema — every type, field, mutation and deprecation note. In development it is essential; in production it hands an attacker a complete map. Disable it, and disable the playground with it.

Authorization belongs in resolvers

The most common GraphQL security failure is not a DoS. It is a resolver that returns an object the REST route correctly protects, because authorization was implemented in controllers rather than in the data layer. Every resolver that returns data needs the same ownership check as the equivalent endpoint.

Errors leak the schema

"Did you mean internalRiskScore?" is a helpful development message and a schema disclosure in production. Disable suggestions.

Example

Example · bash
# Depth attack — small document, enormous cost
query {
  user(id: "1") {
    posts { author { posts { author { posts { author {
      posts { author { posts { author { id title } } } } } } } } } }
  }
}

# Alias attack — one "request", a hundred expensive operations
query {
  a1: expensiveReport(range: "1y") { total }
  a2: expensiveReport(range: "1y") { total }
  # ... a100
}

# Batch attack — one HTTP request, many documents
[ {"query":"{ ... }"}, {"query":"{ ... }"}, ... x1000 ]

# A per-request rate limit counts every one of these as a single request.

When to use it

  • A circular query nested twenty levels deep generates millions of database calls until a depth limit is added.
  • Introspection left enabled in production gives an attacker the full schema including unreleased admin mutations.
  • A resolver returns objects the REST endpoint protects, because ownership checks lived in controllers rather than in the data layer.

More examples

Depth, complexity and batching limits together

Persisted queries are the endgame: if clients can only send hashes of queries you have reviewed, depth and complexity attacks stop being possible at all.

Example · javascript
import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';
import { ApolloServer } from '@apollo/server';

const isProd = process.env.NODE_ENV === 'production';

const server = new ApolloServer({
  schema,

  // 1. Introspection and the playground: development only.
  introspection: !isProd,

  validationRules: [
    // 2. Depth — stops unbounded nesting through circular relations.
    depthLimit(8),

    // 3. Complexity — the one that actually bounds cost.
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 10,
      listFactor: 20,          // a list multiplies the cost of its children
      formatErrorMessage: () => 'Query is too complex.',   // do not reveal the budget
    }),

    // 4. Aliases — one document must not repeat an expensive field 100 times.
    aliasLimitRule(15),
  ],

  // 5. Errors must not teach the schema.
  formatError: (formattedError, error) => {
    logger.error({ err: error }, 'graphql error');       // full detail internally

    if (!isProd) return formattedError;

    // "Did you mean 'internalRiskScore'?" is a schema disclosure.
    if (formattedError.message.includes('Did you mean')) {
      return { message: 'Bad request', extensions: { code: 'BAD_USER_INPUT' } };
    }
    if (formattedError.extensions?.code === 'INTERNAL_SERVER_ERROR') {
      return { message: 'Internal server error',
               extensions: { code: 'INTERNAL_SERVER_ERROR', requestId: currentRequestId() } };
    }
    return formattedError;
  },

  plugins: [
    // 6. Rate limit on COMPLEXITY, not on request count.
    {
      async requestDidStart() {
        return {
          async didResolveOperation({ request, document, contextValue }) {
            const cost = estimateComplexity(document, schema, request.variables);
            const { allowed, retryAfter } = await consume(
              `gql:${contextValue.user?.id ?? contextValue.ip}`,
              { capacity: 5000, refillPerSecond: 50, cost });
            if (!allowed) {
              throw new GraphQLError('Rate limit exceeded', {
                extensions: { code: 'RATE_LIMITED', retryAfter },
              });
            }
          },
        };
      },
    },
  ],
});

// 7. Batching: one HTTP request must not carry a thousand documents.
app.use('/graphql', (req, res, next) => {
  if (Array.isArray(req.body) && req.body.length > 5) {
    return res.status(400).json({ errors: [{ message: 'Batch too large' }] });
  }
  next();
});

// 8. And in production, persisted queries only — the client sends a hash of a
//    query you approved, so arbitrary documents are simply not accepted.
//    This is the strongest control available and it removes most of the above.

Authorization in resolvers, not controllers

Returning null rather than throwing from the loader is deliberate: a distinct error for 'exists but forbidden' would reintroduce the existence oracle.

Example · javascript
// The most common GraphQL finding is not a DoS. It is a resolver returning
// what the REST route correctly protects.

// ❌ Ownership checked in the top-level resolver only
const resolvers = {
  Query: {
    invoice: async (_, { id }, ctx) => {
      const invoice = await db('invoices').where({ id, user_id: ctx.user.id }).first();
      return invoice;                       // ✅ this one is fine
    },
  },
  Invoice: {
    // ❌ but a nested resolver reaches anywhere
    customer: (invoice) => db('customers').where({ id: invoice.customer_id }).first(),
    // and the customer object then exposes THEIR other invoices...
  },
  Customer: {
    invoices: (customer) => db('invoices').where({ customer_id: customer.id }),
    // ...including ones belonging to other users of the same customer record.
  },
};

// ✅ Every resolver that returns data enforces the same rules.
const resolvers = {
  Query: {
    invoice: (_, { id }, ctx) => ctx.loaders.invoice.load(id),   // scoped loader
  },
  Invoice: {
    customer: (invoice, _, ctx) => ctx.loaders.customer.load(invoice.customerId),
    lines: (invoice, _, ctx) => ctx.loaders.linesByInvoice.load(invoice.id),
  },
  Customer: {
    invoices: (customer, { first = 20 }, ctx) =>
      ctx.loaders.invoicesByCustomer.load({
        customerId: customer.id, limit: Math.min(first, 100),
      }),
  },
};

// The loaders are constructed PER REQUEST, closed over the authenticated user,
// so no resolver can reach outside the caller's scope.
export function createLoaders(user) {
  if (!user) throw new Error('loaders require a user');

  return {
    invoice: new DataLoader(async (ids) => {
      const rows = await db('invoices')
        .whereIn('id', ids)
        .andWhere({ user_id: user.id });          // ← authorization, always
      const byId = new Map(rows.map((r) => [r.id, r]));
      return ids.map((id) => byId.get(id) ?? null);   // null, not an error
    }),

    customer: new DataLoader(async (ids) => {
      const rows = await db('customers')
        .whereIn('id', ids)
        .andWhere({ user_id: user.id });
      const byId = new Map(rows.map((r) => [r.id, r]));
      return ids.map((id) => byId.get(id) ?? null);
    }),
  };
}

// DataLoader also solves the N+1 that GraphQL makes so easy to create —
// so the same change fixes the performance problem and the security one.

// And the test, run against BOTH surfaces:
it('graphql enforces the same rules as REST', async () => {
  const rest = await request(app).get(`/api/invoices/${bobInvoice.id}`)
    .set('Authorization', `Bearer ${aliceToken}`);
  const gql = await graphql(`{ invoice(id: "${bobInvoice.id}") { id total } }`, aliceToken);

  expect(rest.status).toBe(404);
  expect(gql.data.invoice).toBeNull();
});

Production configuration, verified

Field-name suggestions are the overlooked one: they let an attacker reconstruct the schema field by field even with introspection disabled.

Example · bash
# Introspection and the playground are the two settings that get left on.

# ── Is introspection enabled? ────────────────────────────────────────
curl -s -X POST https://dfg.com/graphql \
  -H 'Content-Type: application/json' \
  -d '{"query":"{ __schema { types { name fields { name } } } }"}' | head -c 300

# If this returns a schema, an attacker has your complete API surface:
# every type, every field, every mutation, every deprecated-but-still-working
# endpoint, and the admin operations you have not announced.

# ── Is the playground exposed? ───────────────────────────────────────
for p in /graphql /graphiql /playground /altair /voyager; do
  code=$(curl -s -o /dev/null -w '%{http_code}' "https://dfg.com$p")
  [ "$code" = "200" ] && echo "EXPOSED: $p"
done

# ── Do error messages suggest field names? ───────────────────────────
curl -s -X POST https://dfg.com/graphql -H 'Content-Type: application/json' \
  -d '{"query":"{ user(id:\"1\") { internalRisk } }"}' | jq -r '.errors[].message'
# ❌ "Cannot query field 'internalRisk' on type 'User'. Did you mean 'internalRiskScore'?"
#    Field-name suggestions reconstruct the schema even with introspection off.
# ✅ "Bad request"

# ── Is depth bounded? ────────────────────────────────────────────────
python3 - <<'PY'
import json, requests
q = "query{user(id:\"1\")" + "{posts{author" * 15 + "{id}" + "}}" * 15 + "}"
r = requests.post("https://dfg.com/graphql", json={"query": q}, timeout=30)
print(r.status_code, r.text[:200])
PY
# Expect a validation error, not a 30-second wait.

# ── Is batching bounded? ─────────────────────────────────────────────
curl -s -X POST https://dfg.com/graphql -H 'Content-Type: application/json' \
  -d "$(python3 -c 'import json;print(json.dumps([{"query":"{__typename}"}]*1000))')" \
  -o /dev/null -w '%{http_code} %{time_total}s\n'
# Expect 400, fast.

# Run all five after every deploy. They take ten seconds and they catch the
# configuration drift that unit tests structurally cannot.

Discussion

  • Be the first to comment on this lesson.