Topic: mcp server graphql security

MCP server GraphQL security — introspection exposure, query depth limits, batching attacks, and field-level authorization

MCP servers that expose GraphQL APIs or proxy GraphQL calls on behalf of agents face a distinct attack surface from REST APIs. GraphQL's flexible query language lets a single request traverse deeply nested relationships, introspect the entire schema, or batch hundreds of independent operations into one HTTP call. Each capability is also an attack vector. The four patterns below are the highest-impact GraphQL hardening steps for MCP server authors.

Pattern 1: Introspection exposure — mapping your schema for free

GraphQL introspection allows any client to query __schema and receive the complete type system: every type name, every field name, every argument, and every enum value. In development this is invaluable for tooling. In production it gives attackers a roadmap: they can identify user ID fields, internal admin mutations, and deprecated fields that have weaker validation. They can also use the schema to construct valid-looking queries that exercise edge cases in your resolvers.

The fix is to disable introspection in non-development environments. Most GraphQL servers in the Node.js ecosystem support this via a validation rule. The rule should also block __type queries that retrieve type information for individual named types — these can be used to extract schema information incrementally even when full introspection is disabled.

WRONG — introspection enabled in all environments

import { ApolloServer } from '@apollo/server';

// WRONG: no introspection restriction — schema is fully readable by any client
const server = new ApolloServer({
  typeDefs,
  resolvers,
  // introspection defaults to true in all environments
});

RIGHT — disable introspection in production, block __type queries too

import { ApolloServer } from '@apollo/server';
import { NoSchemaIntrospectionCustomRule } from 'graphql';

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

const server = new ApolloServer({
  typeDefs,
  resolvers,
  // RIGHT: disable introspection entirely in production
  introspection: !isProd,
  // RIGHT: add a validation rule that blocks __type and __schema queries
  validationRules: isProd ? [NoSchemaIntrospectionCustomRule] : [],
});

Pattern 2: Unbounded query depth — resolver storms and stack overflows

A GraphQL query can request nested relationships to any depth the schema permits. A schema with circular or deeply-nested types — users have posts, posts have comments, comments have authors, authors have posts — allows a single query to trigger thousands of database calls. With a depth limit of 100, an attacker can craft a query that resolves exponentially growing node sets, flooding the database connection pool before any rate limit applies to the HTTP layer.

Depth limits and complexity budgets address this at the query validation layer — before any resolver executes. Depth limits set an absolute cap on nesting levels. Complexity budgets assign a cost to each field and reject queries whose total cost exceeds a threshold. Both are implemented as GraphQL validation rules that run synchronously before execution.

WRONG — no depth or complexity limit

// WRONG: any query depth accepted — attacker can nest to 1000 levels
// triggering O(n) database calls on a single HTTP request
const server = new ApolloServer({
  typeDefs,
  resolvers,
});

RIGHT — depth limit and complexity budget via validation rules

import { ApolloServer } from '@apollo/server';
import depthLimit from 'graphql-depth-limit';
import { createComplexityRule, simpleEstimator } from 'graphql-query-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    // RIGHT: reject queries nested beyond 6 levels
    depthLimit(6, { ignore: ['__schema'] }),

    // RIGHT: reject queries with combined field cost above 1000
    createComplexityRule({
      maximumComplexity: 1000,
      estimators: [
        simpleEstimator({ defaultComplexity: 1 }),
      ],
      onComplete(complexity) {
        if (complexity > 500) {
          console.log(JSON.stringify({ event: 'high_complexity_query', complexity }));
        }
      },
    }),
  ],
});

Pattern 3: Batching attacks — rate limiting evasion via array requests

GraphQL servers often support batched requests: an array of operation objects sent in a single HTTP POST, each with its own query and variables. A server that rate-limits by HTTP request allows an attacker to pack 500 mutations into one HTTP call, bypassing any per-request limit. The fix has two parts: limit the batch size, and apply rate limits per operation rather than per HTTP request.

WRONG — unlimited batch size, HTTP-layer rate limiting only

// WRONG: no batch size limit — attacker packs 500 mutations per HTTP request
// HTTP rate limiter counts only 1 request
app.post('/graphql', expressRateLimit({ max: 100, windowMs: 60_000 }), graphqlHandler);

RIGHT — batch size cap and per-operation rate counting

import Bottleneck from 'bottleneck';

const MAX_BATCH_SIZE = 20;
const operationLimiter = new Bottleneck({
  maxConcurrent: 10,
  minTime: 10,
  reservoir: 100,
  reservoirRefreshAmount: 100,
  reservoirRefreshInterval: 60_000,
});

async function graphqlBatchHandler(req, res) {
  const body = Array.isArray(req.body) ? req.body : [req.body];

  // RIGHT: reject oversized batches before any parsing
  if (body.length > MAX_BATCH_SIZE) {
    return res.status(400).json({
      errors: [{ message: `Batch size ${body.length} exceeds limit of ${MAX_BATCH_SIZE}` }],
    });
  }

  // RIGHT: consume one token per operation, not one per HTTP request
  const results = await Promise.all(
    body.map(operation =>
      operationLimiter.schedule(() => executeOperation(operation, req.context))
    )
  );

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

Pattern 4: Missing field-level authorization — object-level access without field checks

A common GraphQL authorization mistake is to verify permissions at the query or mutation level — "is the caller allowed to run getUser?" — without verifying that the caller can access each field returned. A schema where the User type includes an internalNotes field and a paymentMethod field will return all of them to any caller who passes the top-level auth check.

Field-level authorization runs inside the resolver for each sensitive field. The resolver checks whether the current context has permission to return that specific value before doing so.

WRONG — authorization only at the query level

const resolvers = {
  Query: {
    // WRONG: checks auth once at the top level — all User fields returned to anyone
    getUser: (_, { id }, context) => {
      if (!context.user) throw new AuthenticationError('Not authenticated');
      return db.users.findById(id);
    },
  },
  User: {
    // WRONG: no field-level check — sensitive fields returned to all authenticated users
    internalNotes: (user) => user.internalNotes,
    paymentMethod: (user) => user.paymentMethod,
  },
};

RIGHT — per-field authorization checks on sensitive resolvers

import { ForbiddenError } from '@apollo/server/errors';

const resolvers = {
  Query: {
    getUser: (_, { id }, context) => {
      if (!context.user) throw new AuthenticationError('Not authenticated');
      return db.users.findById(id);
    },
  },
  User: {
    // RIGHT: field resolver checks that requester is the owner or has admin role
    internalNotes: (user, _, context) => {
      if (!context.user || (context.user.id !== user.id && !context.user.roles.includes('admin'))) {
        throw new ForbiddenError('Cannot access internalNotes');
      }
      return user.internalNotes;
    },
    // RIGHT: payment method only accessible to the account owner
    paymentMethod: (user, _, context) => {
      if (!context.user || context.user.id !== user.id) {
        throw new ForbiddenError('Cannot access paymentMethod');
      }
      return user.paymentMethod;
    },
    displayName: (user) => user.displayName,
    avatarUrl: (user) => user.avatarUrl,
  },
};