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:
- graphql introspection enabled — GraphQL endpoints that do not include
NoSchemaIntrospectionCustomRuleor equivalent introspection disable - no query depth limit — GraphQL execution without depth validation middleware
- no complexity limit — GraphQL queries passed directly to
graphqlClient.request()without complexity analysis - query-level auth only — authorization checks on operation name without field-level resolver authorization
- no alias count limit — GraphQL queries that permit unlimited aliased field selections
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
- MCP server GraphQL security — general GraphQL security patterns for MCP servers
- MCP server rate limit security — tool-call-level rate limiting to complement GraphQL-level controls
- MCP server BOLA/IDOR security — object-level authorization in MCP tool data access
- Anatomy of a prompt injection attack — how LLM-driven injection delivers GraphQL depth bombs
- Multi-agent MCP security — security challenges when multiple agents share a GraphQL MCP interface