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
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 →