Topic: mcp server GraphQL injection security

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

MCP servers that proxy GraphQL APIs create a uniquely dangerous combination: an LLM that can construct arbitrary GraphQL queries, operating against an API designed to accept rich structured queries. An LLM is far more capable than a human at discovering GraphQL schema structure, crafting deeply nested queries, and exploiting alias batching to amplify a single tool call into thousands of resolver invocations. Introspection gating, depth limiting, alias counting, and field-level authorization are the required controls — not performance optimizations.

Attack 1 — introspection-guided schema enumeration

GraphQL introspection allows any client to query the API's full schema: every type, every field, every mutation, every relationship. In a traditional web application context, introspection is a development convenience that should be disabled in production. In an MCP context, an LLM with access to a run_graphql_query tool can use introspection to enumerate the complete API surface in a single tool call, then use that enumeration to craft targeted queries to sensitive fields the server operator did not expect to expose through the MCP interface:

// The introspection query an LLM will run first
const INTROSPECTION_QUERY = `
  query IntrospectionQuery {
    __schema {
      types {
        name
        fields {
          name
          type { name kind }
          args { name type { name kind } }
        }
      }
      mutationType { name fields { name } }
    }
  }
`;

// A vulnerable MCP tool that passes the query directly to GraphQL
export async function run_graphql_query(args: { query: string; variables?: object }) {
  // VULNERABILITY: introspection not disabled, query not validated
  const result = await graphqlClient.request(args.query, args.variables);
  return result;
}

// After the LLM runs introspection, it finds:
// Type: AdminUser { email, passwordHash, apiKey, isAdmin, billingInfo }
// Mutation: updateUserRole(userId: ID!, role: String!): User
//
// The LLM then crafts targeted queries for these fields without the
// tool operator knowing those fields exist or are accessible.
// Fixed: disable introspection and validate allowed operations
import { NoSchemaIntrospectionCustomRule } from 'graphql';
import { validate, parse } from 'graphql';

const ALLOWED_OPERATIONS = new Set(['GetPublicData', 'SearchProducts', 'ReadDocument']);

export async function run_graphql_query(args: { query: string; variables?: object }) {
  // Parse and validate the query
  let document;
  try {
    document = parse(args.query);
  } catch {
    throw new Error('Invalid GraphQL syntax');
  }

  // Block introspection queries
  const introspectionErrors = validate(schema, document, [NoSchemaIntrospectionCustomRule]);
  if (introspectionErrors.length > 0) {
    throw new Error('Introspection queries are not permitted through this interface');
  }

  // Allowlist: only named, pre-approved operations
  const operationNames = document.definitions
    .filter(def => def.kind === 'OperationDefinition')
    .map(def => def.name?.value);

  if (operationNames.some(name => !ALLOWED_OPERATIONS.has(name ?? ''))) {
    throw new Error(`Only permitted operations are allowed: ${[...ALLOWED_OPERATIONS].join(', ')}`);
  }

  return graphqlClient.request(args.query, args.variables);
}

Attack 2 — query depth amplification

GraphQL allows arbitrarily nested queries. A deeply nested query that follows recursive relationships — users → posts → comments → likes → users → posts → ... — resolves to an exponential number of database queries. A single deeply-nested MCP tool call can exhaust a database connection pool, cause resolver timeouts that cascade to server restarts, or generate enough database load to cause a service outage:

// Query that appears syntactically valid but causes exponential resolver load
// At depth 8 with branching factor 5, this resolves ~390,625 database queries
const DEPTH_BOMB = `
  query DeepQuery {
    users {                           # 1 query → 100 users
      posts {                         # 100 queries → 500 posts
        comments {                    # 500 queries → 2,500 comments
          author {                    # 2,500 queries → 2,500 users
            posts {                   # 2,500 queries → 12,500 posts
              comments {              # 12,500 queries → 62,500 comments
                author {              # 62,500 queries → 62,500 users
                  email               # Total: ~140,000 resolver invocations
                }
              }
            }
          }
        }
      }
    }
  }
`;

// Fixed: enforce query depth limit
function measureQueryDepth(
  selectionSet: SelectionSetNode | undefined,
  currentDepth: number = 0
): number {
  if (!selectionSet) return currentDepth;
  let maxDepth = currentDepth;
  for (const selection of selectionSet.selections) {
    if (selection.kind === 'Field' && selection.selectionSet) {
      const depth = measureQueryDepth(selection.selectionSet, currentDepth + 1);
      maxDepth = Math.max(maxDepth, depth);
    }
  }
  return maxDepth;
}

export async function run_graphql_query(args: { query: string }) {
  const document = parse(args.query);
  const MAX_DEPTH = 4;  // 4 levels is sufficient for most legitimate queries

  for (const def of document.definitions) {
    if (def.kind === 'OperationDefinition') {
      const depth = measureQueryDepth(def.selectionSet);
      if (depth > MAX_DEPTH) {
        throw new Error(`Query depth ${depth} exceeds maximum allowed depth of ${MAX_DEPTH}`);
      }
    }
  }

  return graphqlClient.request(args.query);
}

Attack 3 — alias batching for rate limit bypass

GraphQL aliases allow multiple queries to be batched under different names in a single request. A single tool call can disguise thousands of parallel requests as one operation, bypassing rate limits that operate at the query level rather than the resolver level:

// A single GraphQL query that is actually 1,000 parallel requests via aliases
// Rate limiters that count "1 query" are completely bypassed
const ALIAS_BATCH_ATTACK = `
  query BatchAttack {
    user1: user(id: "1") { email passwordHash }
    user2: user(id: "2") { email passwordHash }
    user3: user(id: "3") { email passwordHash }
    # ... repeated 997 more times by an LLM that knows the pattern
    user1000: user(id: "1000") { email passwordHash }
  }
`;

// Fixed: count alias fields and enforce a per-query alias limit
function countAliases(document: DocumentNode): number {
  let count = 0;
  for (const def of document.definitions) {
    if (def.kind === 'OperationDefinition') {
      for (const sel of def.selectionSet.selections) {
        if (sel.kind === 'Field' && sel.alias) count++;
      }
    }
  }
  return count;
}

export async function run_graphql_query(args: { query: string }) {
  const document = parse(args.query);
  const MAX_ALIASES = 10;  // legitimate multi-fetch needs rarely exceed 10

  if (countAliases(document) > MAX_ALIASES) {
    throw new Error(`Query uses too many aliases (>${MAX_ALIASES}). Use pagination instead.`);
  }

  return graphqlClient.request(args.query);
}

Attack 4 — field-level authorization bypass

GraphQL's type system makes it easy to return the same type from multiple query paths. If authorization is applied at the query level (checking the operation name) rather than the field level (checking each field resolver), an LLM can reach sensitive fields through non-obvious query paths:

// Type-level authorization vulnerability — adminUsers is protected,
// but the User type's sensitive fields are returned by the public users query
const typeDefs = gql`
  type User {
    id: ID!
    name: String!
    email: String!
    passwordHash: String!   # Should only be accessible to admins
    billingInfo: BillingInfo!  # Should only be accessible to the user themselves
  }

  type Query {
    users: [User!]!          # Public — but returns User type with sensitive fields
    adminUsers: [User!]!     # Protected — but same User type
  }
`;

// An LLM will query 'users' and include 'passwordHash' in the selection set.
// The query-level auth check on adminUsers does not protect against this path.

// Fixed: field-level authorization resolver
const resolvers = {
  User: {
    passwordHash: (parent, args, context) => {
      // Field-level check — runs regardless of which query returned this User
      if (!context.user?.isAdmin) {
        throw new ForbiddenError('Access denied to passwordHash');
      }
      return parent.passwordHash;
    },
    billingInfo: (parent, args, context) => {
      if (!context.user || context.user.id !== parent.id) {
        throw new ForbiddenError('Access denied to billingInfo');
      }
      return parent.billingInfo;
    }
  }
};

Defense-in-depth configuration for MCP-proxied GraphQL

// Complete GraphQL security configuration for MCP proxy tool
import { createComplexityLimitRule } from 'graphql-query-complexity';

const graphqlSecurityRules = [
  // Block introspection
  NoSchemaIntrospectionCustomRule,

  // Limit query complexity (fields * depth * arguments weight)
  createComplexityLimitRule(200, {
    scalarCost: 1,
    objectCost: 2,
    listFactor: 10,
    introspectionListFactor: 1
  })
];

// Validate every incoming query before execution
export async function run_graphql_query(args: { query: string; variables?: object }) {
  const document = parse(args.query);

  // Run all security validation rules
  const errors = validate(schema, document, graphqlSecurityRules);
  if (errors.length > 0) {
    throw new Error(`Query validation failed: ${errors.map(e => e.message).join('; ')}`);
  }

  // Depth check
  if (getMaxDepth(document) > 4) throw new Error('Query depth exceeds limit');

  // Alias count check
  if (countAliases(document) > 10) throw new Error('Too many aliased fields');

  // Execute with timeout
  return Promise.race([
    graphqlClient.request(args.query, args.variables),
    new Promise((_, reject) => setTimeout(() => reject(new Error('Query timeout')), 5000))
  ]);
}

SkillAudit detection

SkillAudit's Security and Permissions Hygiene axes flag these GraphQL security patterns in MCP server implementations:

Scan your MCP server's GraphQL proxy

SkillAudit detects missing introspection gating, depth limit enforcement, and field-level authorization gaps in MCP server GraphQL proxy implementations.

Request a free audit →

Related security topics