MCP Server Security — GraphQL Batching

MCP server GraphQL query batching security — DoS, auth bypass, and DataLoader abuse

MCP servers that expose a GraphQL API inherit a class of vulnerabilities that does not exist in REST: the batching attack surface. GraphQL clients can send an array of operation objects in a single HTTP request, and DataLoader aggregates individual resolver calls into batch database queries. Both features are performance optimisations — and both can be weaponised. A single HTTP request carrying 1,000 operations is a DoS vector with no additional network cost to the attacker. A batch that mixes authorised and unauthorised operations can bypass per-batch authorization checks. And DataLoader's request-scoped cache silently returns stale values when a write operation precedes a read in the same batch. This page covers the attack model and the Node.js fixes for each of the four patterns.

Pattern 1: Batch array DoS — thousands of operations in one HTTP request

The GraphQL over HTTP specification allows a client to send an array of operation objects in the request body. Apollo Client, urql, and most other GraphQL clients support this for request coalescing. Without an explicit limit, a single POST with 1,000 operation objects executes 1,000 GraphQL operations under one rate-limit counter (the HTTP request), one authentication check, and one connection. The server's CPU and downstream API call budget absorb the full amplification.

// ATTACK PAYLOAD — 1000 operations in a single HTTP request body
// curl -X POST https://mcp-server.example.com/graphql \
//   -H 'Content-Type: application/json' \
//   -d '[{"query":"{ expensiveReport { rows } }"},... × 1000]'

const attackPayload = Array.from({ length: 1000 }, () => ({
  query: `{ expensiveReport { rows { id value metadata } } }`,
}));

// Each operation executes independently and in full.
// The total cost to the attacker: 1 TCP connection, 1 HTTP request.
// The total cost to the server: 1000 × (DB queries + compute + egress).
// VULNERABLE middleware — no batch size check
app.post('/graphql', express.json(), async (req, res) => {
  const body = req.body; // could be an array of 1000 operations
  const result = await graphql({ schema, source: body.query, ... });
  res.json(result);
});

// FIXED middleware — enforce MAX_BATCH_SIZE before execution
const MAX_BATCH_SIZE = 10;

app.post('/graphql', express.json({ limit: '100kb' }), async (req, res) => {
  const body = req.body;

  if (Array.isArray(body)) {
    if (body.length > MAX_BATCH_SIZE) {
      return res.status(400).json({
        errors: [{
          message: `Batch size ${body.length} exceeds maximum of ${MAX_BATCH_SIZE}`,
        }],
      });
    }
    // Execute each operation, collect results
    const results = await Promise.all(
      body.map(op => graphql({ schema, source: op.query, variableValues: op.variables }))
    );
    return res.json(results);
  }

  // Single operation path
  const result = await graphql({ schema, source: body.query, variableValues: body.variables });
  res.json(result);
});

Also set a hard limit on the request body size with express.json({ limit: '100kb' }) — a 10-operation batch of simple queries should be well under 100 KB. A 100 MB JSON array of operations should be rejected at the body parser before it is even parsed. The two limits work together: byte size catches payload inflation, operation count catches semantic amplification.

Pattern 2: DataLoader key explosion — unlimited batch keys from a single query

DataLoader solves the GraphQL N+1 problem by coalescing individual load(key) calls within a single event loop tick into one batchLoadFn(keys) call. The default DataLoader constructor has no maxBatchSize — if a single GraphQL query resolves a list of 500 users and then resolves each user's private profile, DataLoader calls batchLoadFn with all 500 user IDs in a single batch. A purpose-crafted query can push this into the thousands.

// ATTACK QUERY — resolves 500 user IDs then fetches each user's private data
const amplificationQuery = `
  query {
    listUsers(limit: 500) {   # returns 500 user objects
      id
      privateProfile {        # each triggers userLoader.load(id)
        ssn                   # sensitive field
        creditScore
        homeAddress
      }
    }
  }
`;

// DataLoader batches all 500 .load() calls into one batchLoadFn invocation.
// If batchLoadFn fetches from a DB with no row limit, this is a single
// query returning 500 rows of PII from a "cheap" GraphQL operation.
// VULNERABLE DataLoader setup — no maxBatchSize, no key deduplication limit
import DataLoader from 'dataloader';

const userLoader = new DataLoader(
  async (ids: readonly string[]) => {
    // ids could be 5000 entries — no server-side cap
    const rows = await db.query(
      `SELECT * FROM users WHERE id = ANY($1)`,
      [ids]
    );
    return ids.map(id => rows.find(r => r.id === id) ?? null);
  }
);

// FIXED DataLoader setup — bounded batch size and unique key limit
const MAX_BATCH_KEYS   = 100;  // max DB keys per batch call
const MAX_UNIQUE_KEYS  = 500;  // max unique keys across whole request

// Track unique keys per request in context (set up in middleware)
let requestKeyCount = 0;

const userLoader = new DataLoader(
  async (ids: readonly string[]) => {
    const rows = await db.query(
      `SELECT * FROM users WHERE id = ANY($1) LIMIT ${MAX_BATCH_KEYS}`,
      [ids]
    );
    return ids.map(id => rows.find(r => r.id === id) ?? null);
  },
  {
    maxBatchSize: MAX_BATCH_KEYS,   // DataLoader splits larger batches automatically
    cacheKeyFn: (key: string) => {
      requestKeyCount++;
      if (requestKeyCount > MAX_UNIQUE_KEYS) {
        throw new Error(
          `Query resolved more than ${MAX_UNIQUE_KEYS} unique keys — ` +
          `use pagination instead of resolving the full list.`
        );
      }
      return key;
    },
  }
);

Create a new DataLoader instance per request (not per server startup) so that requestKeyCount and the internal cache are scoped to a single operation. Pass the loaders through GraphQL context: contextValue: { loaders: createLoaders() }. A DataLoader instance shared across requests leaks cached values between users and accumulates memory without bound.

Pattern 3: Authorization bypass via per-batch auth check

The most dangerous batching vulnerability is architectural: middleware that authenticates the HTTP request once and then executes all operations in the batch under the same authorization context, without re-checking authorization per operation. An attacker who has a valid token for operation A can include operation B (which their token does not authorize) in the same batch, and operation B executes if the server checks auth only at the batch level.

// VULNERABLE — auth checked once per HTTP request, not per operation
app.post('/graphql', authenticate, async (req, res) => {
  const body = req.body;
  const user = req.user; // set by authenticate middleware

  if (Array.isArray(body)) {
    // Auth was checked for the HTTP request, not for each operation.
    // body[0] may be an admin mutation the user's token doesn't allow.
    const results = await Promise.all(
      body.map(op => graphql({
        schema,
        source: op.query,
        variableValues: op.variables,
        contextValue: { user },  // same user for all — no per-op check
      }))
    );
    return res.json(results);
  }

  // ...
});

// ATTACK: mix an authorized read with an unauthorized admin mutation
const batch = [
  { query: '{ me { id email } }' },                          // authorized
  { query: 'mutation { deleteUser(id: "victim-123") { ok } }' },  // NOT authorized
];
// FIXED — per-operation authorization inside the batch loop
app.post('/graphql', authenticate, async (req, res) => {
  const body = req.body;
  const user = req.user;

  const operations = Array.isArray(body) ? body : [body];

  if (operations.length > MAX_BATCH_SIZE) {
    return res.status(400).json({ errors: [{ message: 'Batch too large' }] });
  }

  const results = await Promise.all(
    operations.map(async op => {
      // Re-derive authorization for this specific operation.
      // checkOperationPermission inspects the parsed AST to determine
      // whether the operation type (query/mutation/subscription) and
      // the root field names are allowed for this user's role.
      const allowed = await checkOperationPermission(op.query, user);
      if (!allowed) {
        return {
          errors: [{
            message: 'Forbidden',
            extensions: { code: 'FORBIDDEN' },
          }],
        };
      }

      return graphql({
        schema,
        source: op.query,
        variableValues: op.variables,
        contextValue: { user },
      });
    })
  );

  // Return array or single object depending on what was sent
  res.json(Array.isArray(body) ? results : results[0]);
});

The checkOperationPermission function should parse the GraphQL document with parse() from graphql-js, walk the AST to find the operation type and root field selections, and check them against an allow-list keyed on the user's role. This is more reliable than inspecting the raw query string with regex — aliases can hide the real field name from a string-based check.

Pattern 4: DataLoader cache TOCTOU with mixed read-write batches

DataLoader's default cache: true option shares resolved values across all operations within a single request. Within a single GraphQL operation this is correct — the same user(id: 1) should not hit the database twice. But in a batch of multiple operations, the cache spans operation boundaries. If operation A resolves user(id: 1) and caches the result, then operation B mutates user(id: 1), then operation C resolves user(id: 1) again, operation C receives the pre-mutation cached value — a TOCTOU (time-of-check/time-of-use) inconsistency.

// SCENARIO: batch with read → write → read on the same entity

const batch = [
  { query: '{ user(id: "1") { email } }' },                  // op A: reads user 1
  { query: 'mutation { updateEmail(id:"1", email:"new@example.com") { ok } }' }, // op B: writes
  { query: '{ user(id: "1") { email } }' },                  // op C: reads user 1
];

// With cache:true (default), op C returns the cached result from op A:
//   { user: { email: "old@example.com" } }
// even though op B just changed it to "new@example.com".

// The mutation succeeded. The read returned stale data.
// A UI built on this batch will display incorrect state.
// OPTION 1: cache:false for any batch containing mutations
// Simpler — no cache at all means every load() hits the DB.
// Use this when the batch processing is not perf-critical.

function createLoaders(hasMutations: boolean) {
  return {
    user: new DataLoader(
      async (ids: readonly string[]) => fetchUsersByIds(ids),
      { cache: !hasMutations }  // disable cache if batch has any mutations
    ),
  };
}

function batchContainsMutations(ops: Array<{ query: string }>): boolean {
  return ops.some(op => {
    const doc = parse(op.query);
    return doc.definitions.some(
      d => d.kind === 'OperationDefinition' && d.operation === 'mutation'
    );
  });
}

// In middleware:
const loaders = createLoaders(batchContainsMutations(operations));
const contextValue = { user, loaders };
// OPTION 2: clearAll() after each mutation operation
// Preserves caching for pure read sequences while clearing stale values
// after any mutation in the batch.

const results: unknown[] = [];

for (const op of operations) {
  const allowed = await checkOperationPermission(op.query, user);
  if (!allowed) {
    results.push({ errors: [{ message: 'Forbidden' }] });
    continue;
  }

  const result = await graphql({
    schema,
    source: op.query,
    variableValues: op.variables,
    contextValue: { user, loaders },
  });
  results.push(result);

  // If this was a mutation, invalidate the DataLoader cache so the
  // next read operation in the batch sees the post-mutation state.
  const doc = parse(op.query);
  const isMutation = doc.definitions.some(
    d => d.kind === 'OperationDefinition' && d.operation === 'mutation'
  );
  if (isMutation) {
    loaders.user.clearAll();
    loaders.post.clearAll();
    // clear all entity loaders that the mutation may have affected
  }
}

res.json(Array.isArray(body) ? results : results[0]);

Note that Option 2 (sequential execution with clearAll()) sacrifices the parallelism of Promise.all() — operations run one after another. This is the correct tradeoff when mutations are in the batch: mutations have ordering semantics that parallel execution cannot guarantee. A batch with only reads can use Promise.all() safely. Gate the execution strategy on batchContainsMutations().

SkillAudit findings

The following findings appear in SkillAudit audit reports for MCP servers that expose a GraphQL endpoint:

CRITICAL  No batch size limit — array body with 1000 operations accepted. The GraphQL HTTP handler does not check Array.isArray(body) or enforce a maximum batch operation count. A single HTTP request can carry an unbounded number of operations, each of which executes independently. This is a denial-of-service vector with a 1:N amplification ratio where N is the number of operations per request.

HIGH  Per-batch authorization check — authorization not verified per operation. The authentication middleware sets the request user once and all operations in a batch execute under the same authorization context without per-operation permission checks. An attacker with a valid token can include unauthorized operations in a batch alongside authorized ones.

HIGH  DataLoader maxBatchSize not configured — unlimited batch keys. One or more DataLoader instances are constructed without maxBatchSize in the options object. A single GraphQL query that resolves a large list and then resolves a nested loader field for each element can generate a batch call with thousands of database keys, bypassing any row-level limits in the resolver layer.

MEDIUM  DataLoader cache: true with mixed read/write batches — stale cache risk. DataLoader is instantiated with the default cache: true and the batch handler does not call clearAll() after mutations or disable caching for mutation-containing batches. Operations that read the same entity after a mutation in the same batch may receive the pre-mutation cached value.

MEDIUM  No per-operation rate limit — rate limiting applied per HTTP request only. The rate limiter counts HTTP requests, not GraphQL operations. A client that sends batches of 10 operations consumes 10× the rate-limited resources per HTTP-request token. Enforce an operation-count multiplier in the rate limiter: each operation in a batch consumes one unit of rate-limit budget.

LOW  No batch execution timeout. The batch handler executes all operations (potentially in parallel) with no overall timeout. A batch of slow operations can hold the event loop and downstream DB connections for an unbounded duration. Apply a Promise.race() against an AbortController-driven timeout at the batch level in addition to any per-operation timeouts in resolvers.

Paste a GitHub URL at skillaudit.dev to get a graded report card.