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.