Topic: mcp server graphql introspection security

MCP server GraphQL introspection security — disabling introspection, query depth limits, and field-level authorization

GraphQL MCP servers expose an introspection endpoint by default that reveals your entire schema — all types, fields, mutations, and their relationships — to any unauthenticated caller. For MCP servers, this creates three compounding risks: schema enumeration enables targeted attacks on sensitive fields, batch query DoS exploits batching to bypass per-request rate limits, and missing field-level authorization allows lateral access to data the caller shouldn't see.

Why GraphQL introspection is dangerous in MCP server deployments

GraphQL's introspection system — the __schema and __type queries — was designed for developer tooling: GraphiQL, Apollo Studio, code generators. In development, this is invaluable. In production MCP servers, it hands attackers a complete map of every query, mutation, field, argument, and type in your API. They don't need to probe blindly; introspection tells them exactly what to target.

For an MCP server, the schema reveals the full surface of what the server can do on behalf of an LLM agent. A mutation like deleteProject(id: ID!) or a query like listApiKeys(userId: ID!) appearing in introspection output is a direct invitation to try calling it without proper authorization.

1. Disabling introspection in production

In Apollo Server 4, pass introspection: false in the server config. For graphql-js directly, add the NoSchemaIntrospectionCustomRule validation rule to all document validation passes:

// Apollo Server 4 — disable introspection in production
import { ApolloServer } from "@apollo/server";
import { NoSchemaIntrospectionCustomRule } from "graphql";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  // In Apollo Server 4, introspection is disabled in production by default
  // but make it explicit:
  introspection: process.env.NODE_ENV !== "production",
  validationRules: process.env.NODE_ENV === "production"
    ? [NoSchemaIntrospectionCustomRule]
    : [],
  plugins: [
    {
      async requestDidStart() {
        return {
          async didResolveOperation({ request, document }) {
            // Extra guard: reject any operation that queries __schema or __type
            const queryStr = request.query ?? "";
            if (queryStr.includes("__schema") || queryStr.includes("__type")) {
              throw new GraphQLError("Introspection is disabled in production", {
                extensions: { code: "INTROSPECTION_DISABLED" },
              });
            }
          },
        };
      },
    },
  ],
});

// graphql-js direct usage
import { validate, execute, parse } from "graphql";
import { NoSchemaIntrospectionCustomRule } from "graphql";

function executeQuery(schema, query, variables, context) {
  const document = parse(query);
  const validationErrors = validate(schema, document, [
    // Standard rules PLUS introspection block
    ...specifiedRules,
    NoSchemaIntrospectionCustomRule,
  ]);

  if (validationErrors.length > 0) {
    return { errors: validationErrors };
  }

  return execute({ schema, document, variableValues: variables, contextValue: context });
}

2. Query depth limits

GraphQL's recursive type system allows deeply nested queries. Without a depth limit, an attacker can construct a query like { user { posts { comments { author { posts { comments { ... } } } } } } } that causes O(depth) database round-trips or exponential data fetching. The graphql-depth-limit package adds a validation rule that rejects queries beyond a configured depth:

import depthLimit from "graphql-depth-limit";
import { ApolloServer } from "@apollo/server";

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: false,
  // Reject queries deeper than 7 levels
  // Adjust based on your schema's deepest legitimate query
  validationRules: [depthLimit(7)],
});

// Manual depth limit without the package (for graphql-js setups)
function queryDepth(selectionSet, depth = 0) {
  if (!selectionSet || !selectionSet.selections) return depth;
  return Math.max(
    ...selectionSet.selections.map((selection) => {
      if (selection.selectionSet) {
        return queryDepth(selection.selectionSet, depth + 1);
      }
      return depth;
    })
  );
}

function validateQueryDepth(document, maxDepth = 7) {
  for (const definition of document.definitions) {
    if (definition.kind === "OperationDefinition") {
      const depth = queryDepth(definition.selectionSet);
      if (depth > maxDepth) {
        throw new GraphQLError(`Query depth ${depth} exceeds limit of ${maxDepth}`, {
          extensions: { code: "QUERY_TOO_DEEP" },
        });
      }
    }
  }
}

3. Query complexity limits

Depth limits prevent deep recursion but don't prevent wide queries: { users { id name email phone address orders { id total items { id } } } } at depth 3 could still fetch millions of records. Complexity limits assign a cost to each field and reject queries whose total cost exceeds a threshold:

import { createComplexityLimitRule } from "graphql-query-complexity";

// Each field costs 1 by default; list fields cost more because they multiply
// the cost of their children by the estimated list size
const ComplexityLimitRule = createComplexityLimitRule(1000, {
  onCost: (cost) => {
    console.log(`Query complexity: ${cost}`);
  },
  createError: (max, actual) => new GraphQLError(
    `Query complexity ${actual} exceeds limit of ${max}`,
    { extensions: { code: "QUERY_TOO_COMPLEX" } }
  ),
  estimators: [
    // List fields multiply their children's cost by the expected list size
    fieldExtensionsEstimator(),
    simpleEstimator({
      defaultComplexity: 1,
    }),
  ],
});

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: false,
  validationRules: [
    depthLimit(7),
    ComplexityLimitRule,
  ],
});

4. Batch query DoS protection

GraphQL clients can send an array of operations in a single HTTP request — query batching. This is useful for network efficiency but allows attackers to send 1000 operations in a single request that your per-request rate limiter counts as one. Limit the maximum number of operations per batch request:

// Middleware to limit batch query size before GraphQL parsing
app.post("/graphql", (req, res, next) => {
  const body = req.body;

  // Batched requests arrive as an array
  if (Array.isArray(body)) {
    const MAX_BATCH_SIZE = 5;
    if (body.length > MAX_BATCH_SIZE) {
      return res.status(400).json({
        errors: [{
          message: `Batch size ${body.length} exceeds maximum of ${MAX_BATCH_SIZE}`,
          extensions: { code: "BATCH_TOO_LARGE" },
        }],
      });
    }
  }

  next();
});

// Apollo Server 4 also supports disabling batching entirely
const server = new ApolloServer({
  typeDefs,
  resolvers,
  // Disable batching if your MCP clients don't need it
  // (MCP tool calls are one-at-a-time, so batching is rarely needed)
  allowBatchedHttpRequests: false,
});

5. Field-level authorization with graphql-shield

Type-level authorization (check once at the type level, all fields inherit the grant) is not sufficient. Fields within the same type often have different sensitivity: User.name is public; User.ssn or User.apiKeys are not. The graphql-shield package applies permission rules at the field level:

import { shield, rule, and, not, allow, deny } from "graphql-shield";
import { applyMiddleware } from "graphql-middleware";

// Rules
const isAuthenticated = rule({ cache: "contextual" })(
  async (parent, args, ctx) => {
    return ctx.session?.authenticated === true;
  }
);

const isAdmin = rule({ cache: "contextual" })(
  async (parent, args, ctx) => {
    return ctx.session?.role === "admin";
  }
);

const isOwner = rule({ cache: "strict" })(
  async (parent, args, ctx) => {
    // parent here is the User object being resolved
    return parent.id === ctx.session?.userId;
  }
);

// Field-level permission matrix
const permissions = shield({
  Query: {
    // Public fields — no auth required
    publicInfo: allow,
    // All other queries require authentication
    "*": isAuthenticated,
  },
  User: {
    // Public fields on the User type
    id: allow,
    name: allow,
    bio: allow,
    // Sensitive fields — only accessible to owner or admin
    email: and(isAuthenticated, isOwner),
    phone: and(isAuthenticated, isOwner),
    apiKeys: and(isAuthenticated, or(isOwner, isAdmin)),
    // Internal fields — admin only
    internalNotes: and(isAuthenticated, isAdmin),
    // Deny access to audit log from regular users
    auditLog: and(isAuthenticated, isAdmin),
  },
  Mutation: {
    // Require admin for all mutations unless specified
    "*": and(isAuthenticated, isAdmin),
    // Users can update their own profile
    updateProfile: and(isAuthenticated, isOwner),
  },
}, {
  // Fail closed — any unmatched field is denied by default
  fallbackRule: deny,
  allowExternalErrors: false, // Never expose resolver errors to the client
});

const schemaWithAuth = applyMiddleware(schema, permissions);

6. Persisted queries — allow only known query hashes

For production MCP servers, the strongest defense is an allowlist of pre-registered query hashes. Only queries whose SHA-256 hash matches a registered entry are executed; arbitrary ad-hoc queries are rejected entirely. This eliminates introspection, depth abuse, and complexity attacks in a single control:

import { createHash } from "crypto";

// At build time, register all legitimate queries by their SHA-256 hash
const ALLOWED_QUERY_HASHES = new Map([
  ["a3f8c2e1d9b4f7a0c6e2d8b5f1a9c3e7", `query GetUser($id: ID!) { user(id: $id) { id name email } }`],
  ["b7d4a1e6c0f9b2e8a5d3c7f4b0e1d6a9", `query ListProjects { projects { id name status } }`],
  ["c2f5b8d4a7e0c3f9b6d2a8e5c1f0b7d4", `mutation CreateProject($input: ProjectInput!) { createProject(input: $input) { id } }`],
]);

function hashQuery(query) {
  return createHash("sha256").update(query.trim()).digest("hex");
}

// Middleware: reject any query not in the allowlist
app.post("/graphql", (req, res, next) => {
  const { query, extensions } = req.body;

  // Support automatic persisted queries (APQ): client sends hash first
  if (extensions?.persistedQuery) {
    const { sha256Hash } = extensions.persistedQuery;
    if (!ALLOWED_QUERY_HASHES.has(sha256Hash)) {
      return res.status(400).json({
        errors: [{ message: "PersistedQueryNotFound", extensions: { code: "PERSISTED_QUERY_NOT_FOUND" } }],
      });
    }
    // Replace query body with the registered query for this hash
    req.body.query = ALLOWED_QUERY_HASHES.get(sha256Hash);
    return next();
  }

  // Ad-hoc queries: only allow if their hash is registered
  if (query) {
    const hash = hashQuery(query);
    if (!ALLOWED_QUERY_HASHES.has(hash)) {
      return res.status(400).json({
        errors: [{ message: "Query not in allowlist. Use persisted queries.", extensions: { code: "QUERY_NOT_ALLOWED" } }],
      });
    }
  }

  next();
});

SkillAudit findings and grade impacts

Finding → Grade Impact
Critical GraphQL introspection enabled in production — full schema exposed to any unauthenticated caller. −25 points.
Critical No query depth limit — deeply nested recursive queries cause exponential resolver execution. −20 points.
High No query complexity limit — wide queries fetch unbounded data even at shallow depth. −15 points.
High No field-level authorization — type-level auth applied but sensitive fields within the type are accessible to all authenticated callers. −12 points.
High No batch query limit — array of 1000 operations in a single HTTP request bypasses per-request rate limiting. −10 points.
Medium No persisted query allowlist — ad-hoc queries allowed in production, enabling targeted schema exploration after partial disclosure. −6 points.
Medium Error messages expose schema details — GraphQL errors reveal field names, type names, or resolver paths to unauthenticated callers. −4 points.

Audit your GraphQL MCP server. SkillAudit's static analysis checks for introspection configuration, missing validation rules (depth/complexity), field-level auth coverage, and batch limit enforcement. Run a free audit →