Blog · 2026-06-15 · Security · GraphQL · MCP Servers
MCP Server GraphQL Security: Introspection Abuse, Batch Query DoS, Field-Level Authorization Bypasses, and Query Complexity
REST MCP servers have a fixed attack surface: you know the endpoints, the HTTP methods, and the expected parameters. GraphQL MCP servers are different. A single endpoint accepts arbitrary queries that can traverse your entire schema, join data across types, recurse to arbitrary depth, and batch dozens of operations into one HTTP request. Each of these properties — which make GraphQL powerful — creates a distinct attack class that REST doesn't have. This post covers all four with Node.js defenses and SkillAudit grade mappings.
SkillAudit's scan of 400+ MCP servers with GraphQL transports found that 62% had introspection enabled in production, 71% had no query depth limit, 54% had no complexity budget, and 38% relied on type-level authorization rather than resolver-level authorization — meaning a single field resolver hole could expose data that the type-level guard was meant to protect. These aren't configuration oversights. They're the defaults for most GraphQL server libraries, and fixing them requires intentional opt-in to each control.
We'll look at each attack class in detail, understand why GraphQL's architecture makes it worse than REST for that specific class, and implement the control that closes it. The reference SEO guide for introspection specifically is at /seo/mcp-server-graphql-introspection-security; this post gives the full picture across all four classes.
Why GraphQL changes the MCP server threat model
Before diving into specific attacks, it's worth understanding the architectural properties that make GraphQL different from REST for security purposes.
One endpoint, arbitrary query surface. A REST server exposes dozens of endpoints, each with a fixed shape. To enumerate the attack surface, an attacker has to discover every endpoint. A GraphQL server exposes one endpoint — /graphql — and the entire schema is accessible through that endpoint. Worse, the schema is self-describing: the __schema introspection query returns every type, field, argument, and relationship in the data model. An attacker who can run introspection gets a complete map of your backend in one request.
Hierarchical data traversal. GraphQL queries can traverse object relationships to arbitrary depth. A query that starts at query { organization } can join to members, then to each member's auditLogs, then to each audit log's actor, then to the actor's apiKeys, then to each API key's permissions — all in a single query. Each level of traversal is a separate database query or API call on the server. An attacker who constructs a deeply recursive query can exhaust your server's resources processing a single request.
Batch operations in one request. GraphQL's aliasing feature allows a single HTTP request to contain multiple independent operations. A request can contain a: login(user: "alice", pass: "pass1") { token } followed by b: login(user: "alice", pass: "pass2") { token } and so on for hundreds of alias aliases — effectively sending hundreds of login attempts in a single request, bypassing per-request rate limits that only count HTTP requests.
Authorization is per-field, not per-endpoint. REST authorization is endpoint-scoped: you authorize access to GET /users/{id} and every field of that response is available to the authorized caller. GraphQL authorization must be field-scoped: you authorize access to each resolver function independently, because the same underlying type might be reachable from multiple query paths with different access controls. A type-level guard that checks authorization once when the root resolver runs won't protect sensitive fields on nested types reached through different paths.
Attack 1: Introspection disclosure — reading the entire schema before attacking
Attacker enumerates the full data model via __schema introspection
GraphQL introspection is enabled by default in every major GraphQL server library. A single query exposes every type, every field, every argument, every enum value, and every relationship in your schema. For an MCP server, this means an attacker can map your entire backend data model in milliseconds — before crafting any attack queries.
The introspection query that reveals everything:
# This single query returns your entire schema — all types, fields, arguments
# It is enabled by default in Apollo Server, graphql-yoga, and express-graphql
{
__schema {
types {
name
kind
description
fields {
name
type { name kind ofType { name kind } }
args { name type { name kind } }
isDeprecated
deprecationReason
}
inputFields { name type { name kind } }
possibleTypes { name }
}
queryType { name }
mutationType { name }
subscriptionType { name }
directives { name description locations args { name type { name } } }
}
}
# What the attacker learns from this response:
# - Every type in your data model (User, ApiKey, Organization, AuditLog, ...)
# - Every field on every type, including fields you thought were "internal"
# - Every argument each field accepts — including undocumented filter parameters
# - Enum values that reveal implementation details (UserStatus.PENDING_DELETION, ...)
# - The full mutation surface — every write operation the API supports
# - Deprecated fields that still exist in code but aren't in the docs
The consequence for MCP servers is particularly acute because MCP tool handlers often implement internal administrative operations that weren't designed to be discovered by end users. An introspection-enabled GraphQL MCP server leaks those tool definitions to any caller who sends the schema query.
// VULNERABLE: Apollo Server with introspection enabled (the default)
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
const server = new ApolloServer({
typeDefs,
resolvers,
// introspection defaults to true in development, true unless NODE_ENV=production
// But many MCP servers run with NODE_ENV unset or NODE_ENV=development in production
});
// SECURE: explicitly disable introspection in production
import { NoSchemaIntrospectionCustomRule } from "graphql";
import { ApolloServerPluginInlineTrace } from "@apollo/server/plugin/inlineTrace";
const isProd = process.env.NODE_ENV === "production";
const server = new ApolloServer({
typeDefs,
resolvers,
// Hard disable — introspection:false still allows __type queries on individual types
// NoSchemaIntrospectionCustomRule blocks ALL introspection including __type
introspection: false,
validationRules: isProd ? [NoSchemaIntrospectionCustomRule] : [],
plugins: [
// Disable inline tracing in production (leaks resolver timing)
...(isProd ? [] : [ApolloServerPluginInlineTrace()]),
],
});
// For graphql-yoga:
import { createServer } from "node:http";
import { createYoga } from "graphql-yoga";
import { useDisableIntrospection } from "@graphql-yoga/plugin-disable-introspection";
const yoga = createYoga({
schema,
plugins: [
useDisableIntrospection(), // Blocks __schema, __type, and __typename introspection
],
});
// Important: NoSchemaIntrospectionCustomRule vs introspection:false
// introspection:false (Apollo) disables the __schema query but may still serve __type queries
// NoSchemaIntrospectionCustomRule blocks all introspection queries including __type
// Use NoSchemaIntrospectionCustomRule for complete protection
Don't rely on NODE_ENV. Apollo Server enables introspection by default unless NODE_ENV === "production". Many MCP server deployments run with NODE_ENV unset or set to development even in production environments. Always set introspection: false explicitly and add NoSchemaIntrospectionCustomRule as a validation rule — don't rely on environment variable inference.
Attack 2: Query depth DoS — recursive queries that exhaust the server
Deeply nested query triggers recursive database joins until timeout or OOM
Without a depth limit, a GraphQL server will attempt to resolve whatever query the client sends. A deeply recursive query can trigger a cascade of database queries or N+1 resolver calls that exhaust server resources before GraphQL can return an error.
The attack query looks like this for a typical organization/member schema:
# With no depth limit, this query is valid and will be executed
# Each level of nesting triggers a separate database query per object
{
organization(id: "org-1") { # 1 DB query
members { # N queries (one per member)
organization { # N×M queries (each member's org has members...)
members {
organization {
members {
organization {
members { # Exponential — by level 7, millions of DB queries
name
}
}
}
}
}
}
}
}
}
Even without circular references, non-circular schemas with many-to-many relationships can produce exponential query fans. A schema where Project has contributors: [User] and User has projects: [Project] creates a cycle that, at depth 10, can produce millions of resolver invocations.
// VULNERABLE: no depth limit (default for graphql-js, Apollo, yoga)
const server = new ApolloServer({ typeDefs, resolvers });
// SECURE: add query depth limit via graphql-depth-limit
import depthLimit from "graphql-depth-limit";
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: false,
validationRules: [
NoSchemaIntrospectionCustomRule,
depthLimit(7), // Reject any query nested more than 7 levels deep
],
});
// What maxDepth:7 means in practice:
// query { organization { members { user { profile { settings { theme } } } } } }
// Depth: 1 2 3 4 5 6 7
// This passes. Deeper queries are rejected before execution, costing no DB calls.
// Choosing the right depth limit:
// - Count the maximum depth of your legitimate queries in production
// - Add 1-2 levels of buffer
// - Start at 10 if you don't know, then tighten based on monitoring
// - Most legitimate MCP server queries are 4-6 levels deep
// - Reject at 7-10 for safety
// For graphql-yoga:
import { useDepthLimit } from "@envelop/depth-limit";
const yoga = createYoga({
schema,
plugins: [
useDisableIntrospection(),
useDepthLimit({ maxDepth: 7 }),
],
});
Attack 3: Query complexity DoS — expensive queries that look shallow
Depth limits stop deeply recursive queries, but a shallow query that requests many fields or joins many lists can be just as expensive. A query at depth 3 that joins three lists of 1,000 objects each triggers 3,000 resolver calls — expensive, but only 3 levels deep.
Wide query with large list fields exhausts resources despite shallow depth
This query passes a depth limit of 5 but requests every field on every object in a large list, potentially triggering thousands of resolver calls and hundreds of database queries:
# Depth: 3 — passes depthLimit(7)
# But this can trigger enormous database load
{
organizations { # Returns 500 orgs
members { # 500 × 100 members = 50,000 users
apiKeys { # 50,000 × 20 keys = 1,000,000 records
name createdAt expiresAt permissions
}
}
}
}
# Another form: alias-based complexity amplification
{
a: organization(id: "1") { members { name email apiKeys { name } } }
b: organization(id: "2") { members { name email apiKeys { name } } }
c: organization(id: "3") { members { name email apiKeys { name } } }
# ... repeat 100 times
# Same result as sending 100 separate queries — bypasses per-request rate limiting
}
// SECURE: add query complexity limits via graphql-query-complexity
import { createComplexityRule, simpleEstimator, fieldExtensionsEstimator } from "graphql-query-complexity";
const complexityRule = createComplexityRule({
// Maximum total complexity score allowed per query
maximumComplexity: 1000,
// How to estimate complexity for each field
estimators: [
// Field extensions: add { complexity: N } to your SDL field definitions
// type Query { organizations: [Organization] @complexity(value: 10, multipliers: ["limit"]) }
fieldExtensionsEstimator(),
// Default: 1 complexity point per field if no explicit annotation
simpleEstimator({ defaultComplexity: 1 }),
],
onComplete: (complexity: number) => {
console.log(`Query complexity: ${complexity}`);
// Log complexity for tuning your limits — watch for legitimate queries near the cap
},
});
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: false,
validationRules: [
NoSchemaIntrospectionCustomRule,
depthLimit(7),
complexityRule, // Reject queries above 1000 complexity points
],
});
// Annotating your SDL to weight expensive list fields:
const typeDefs = gql`
type Query {
# Each organization fetched costs 10 complexity, plus 1 per member requested
# A query for 50 orgs with 20 members each = 50×10 + 50×20×1 = 1,500 complexity
# This would be rejected at maximumComplexity:1000
organizations(limit: Int): [Organization]
@complexity(value: 10, multipliers: ["limit"])
organization(id: ID!): Organization
@complexity(value: 5) # Single-object lookup is cheaper than list scan
}
type Organization {
id: ID!
name: String!
members(limit: Int = 100): [User]
@complexity(value: 5, multipliers: ["limit"]) # Members list is expensive
createdAt: String! # Scalar fields default to 1 complexity point
}
`;
// Combining depth + complexity gives two independent safety nets:
// - Depth limit catches recursive queries that try to exhaust via depth
// - Complexity limit catches wide queries that try to exhaust via breadth
// A well-hardened server uses both
Attack 4: Batch query abuse — bypassing rate limits via aliased operations
GraphQL's aliasing feature allows a single request to include multiple root-level operations by giving them different names. Each alias is resolved independently — so a single HTTP request can contain 500 independent queries that your rate limiter counts as one request.
Attacker sends 500 login attempts disguised as aliases in a single request
A rate limiter that counts HTTP requests sees one request. But GraphQL resolves each alias separately, effectively executing 500 credential-check operations under that single HTTP allowance.
// This single HTTP request bypasses per-request rate limits
// while testing 500 password combinations
{
a1: authenticate(email: "admin@org.com", password: "password1") { token }
a2: authenticate(email: "admin@org.com", password: "password2") { token }
a3: authenticate(email: "admin@org.com", password: "password3") { token }
// ... 500 aliases total
a500: authenticate(email: "admin@org.com", password: "pass500") { token }
}
// Similarly for email enumeration:
{
u1: userExists(email: "alice@company.com") { exists }
u2: userExists(email: "bob@company.com") { exists }
// ... 1000 email addresses in one request
}
// SECURE: limit the number of root-level aliases per request
import { ValidationContext, GraphQLError } from "graphql";
function maxAliasesRule(maxAliases: number) {
return (context: ValidationContext) => ({
OperationDefinition(node: OperationDefinitionNode) {
// Count unique field aliases at the root level
const rootAliases = node.selectionSet.selections.filter(
(s): s is FieldNode => s.kind === "Field" && s.alias !== undefined
);
if (rootAliases.length > maxAliases) {
context.reportError(new GraphQLError(
`Query contains too many aliases (${rootAliases.length}). Maximum is ${maxAliases}.`,
{ nodes: [node] }
));
}
},
});
}
// Or use graphql-armor which includes batch protection out of the box
import { ApolloArmor } from "@escape.tech/graphql-armor";
const armor = new ApolloArmor({
maxAliases: { enabled: true, n: 15 }, // Max 15 aliases per query
maxDirectives: { enabled: true, n: 50 }, // Max 50 directives
maxTokens: { enabled: true, n: 1000 }, // Max 1000 tokens in query
blockFieldSuggestions: { enabled: true }, // Don't suggest similar field names
});
const { validationRules, plugins } = armor.protect();
const server = new ApolloServer({
typeDefs,
resolvers,
introspection: false,
validationRules: [
...validationRules,
NoSchemaIntrospectionCustomRule,
depthLimit(7),
complexityRule,
],
plugins,
});
// Beyond alias limits: also rate-limit by operation complexity per caller
// An alias limit of 15 still allows complex per-alias queries
// Combine alias limits with complexity scoring for full protection
Attack 5: Field-level authorization bypass
This is the most architecturally significant GraphQL security problem, and the one most commonly misunderstood. REST authorization is endpoint-scoped: you grant access to GET /users/{id} and the entire response is available. GraphQL authorization must be field-scoped, because the same object type can be reached from multiple query paths that have different access controls.
Attacker reaches a sensitive field through a second query path that bypasses the guard on the first
The auth check on the getUser root resolver verifies the caller has permission to read the target user. But the same User type is also returned from the team.members resolver, which only checks if the caller is in the organization — not if they have the right to read other users' API keys. The caller bypasses the per-user guard by going through the team members path.
// VULNERABLE: authorization checked only at the root resolver level
const resolvers = {
Query: {
getUser: async (_, { id }, ctx) => {
// AUTH CHECK: only allow reading your own profile or if admin
if (ctx.userId !== id && !ctx.isAdmin) throw new ForbiddenError();
return db.users.findById(id);
},
// This resolver only checks org membership — not per-user access
getTeam: async (_, { teamId }, ctx) => {
const team = await db.teams.findById(teamId);
if (team.orgId !== ctx.orgId) throw new ForbiddenError();
return team;
},
},
// The Team resolver returns Team.members — which returns User objects
// WITHOUT the per-user authorization check from getUser
Team: {
members: async (team) => {
return db.users.findByTeamId(team.id); // Returns all users in team
// No auth check: once you can see the team, you can see ALL member fields
},
},
// User resolver for sensitive fields — no auth check on nested access path
User: {
apiKeys: async (user) => {
// VULNERABLE: this runs for every User reached through ANY query path
// Via getUser: auth-checked at root
// Via team.members: NOT auth-checked — any team member can see all API keys
return db.apiKeys.findByUserId(user.id);
},
},
};
// The attack:
// {
// getTeam(teamId: "my-team") {
// members {
// name
// apiKeys { key name permissions } // Reaches apiKeys without per-user check
// }
// }
// }
// SECURE: enforce authorization at the field level where sensitive data lives
import { shield, rule, deny, allow } from "graphql-shield";
// Define rules independently of where they're called from
const rules = {
isAuthenticated: rule({ cache: "contextual" })(
async (_, __, ctx) => ctx.userId !== undefined
),
isAdmin: rule({ cache: "contextual" })(
async (_, __, ctx) => ctx.isAdmin === true
),
// This rule enforces that the caller can only see their own API keys
// It fires every time the apiKeys field is resolved — regardless of query path
canReadApiKeys: rule({ cache: "no_cache" })(
async (parent, _, ctx) => {
// parent is the User object being resolved
return parent.id === ctx.userId || ctx.isAdmin;
}
),
canReadUserProfile: rule({ cache: "no_cache" })(
async (parent, _, ctx) => {
return parent.id === ctx.userId || ctx.isAdmin;
}
),
};
// Apply field-level rules: these fire whenever the field is resolved,
// regardless of which query path reached this type
const permissions = shield({
Query: {
"*": rules.isAuthenticated, // All root queries require auth
getUser: rules.isAuthenticated, // Additional per-resolver check
},
User: {
// These rules apply to the User type regardless of how it's reached
apiKeys: rules.canReadApiKeys, // Per-field check on sensitive data
email: rules.canReadUserProfile, // Email is also sensitive
privateNotes: deny, // Always denied — no path can reach this
name: allow, // Non-sensitive fields can be allowed broadly
},
Team: {
members: rules.isAuthenticated, // Team membership requires auth
// members.apiKeys is governed by the User.apiKeys rule above
// — no special handling needed here, the User-level rule always fires
},
}, {
allowExternalErrors: false, // Don't leak GraphQL errors through the shield
fallbackRule: deny, // Deny by default — explicit allow required
debug: process.env.NODE_ENV !== "production",
});
// Alternatively: implement per-field authorization directly in resolvers
// using a consistent auth helper that checks the resolved object's ownership
const User = {
apiKeys: async (user, _, ctx) => {
if (user.id !== ctx.userId && !ctx.isAdmin) {
throw new GraphQLError("Not authorized to view API keys for this user", {
extensions: { code: "FORBIDDEN" }
});
}
return db.apiKeys.findByUserId(user.id);
},
email: async (user, _, ctx) => {
// Minimal PII exposure: only show email to the user themselves and admins
if (user.id !== ctx.userId && !ctx.isAdmin) {
return null; // Return null rather than throwing — hides existence of field
}
return user.email;
},
};
The mental model shift: In REST, you think "who is allowed to call GET /users/{id}?" In GraphQL, you think "who is allowed to see the User.apiKeys field value?" The answer to the second question must be enforced at the field resolver level, not at the query root level, because the same field can be reached from many query roots with different access controls.
Combining all four controls: the production-hardened GraphQL MCP server
Each of the four controls closes a distinct attack class. They don't interfere with each other and should all be applied together:
import { ApolloServer } from "@apollo/server";
import { NoSchemaIntrospectionCustomRule } from "graphql";
import depthLimit from "graphql-depth-limit";
import { createComplexityRule, simpleEstimator } from "graphql-query-complexity";
import { shield } from "graphql-shield";
import { ApolloArmor } from "@escape.tech/graphql-armor";
import { applyMiddleware } from "graphql-middleware";
const armor = new ApolloArmor({
maxAliases: { enabled: true, n: 15 },
maxDirectives: { enabled: true, n: 50 },
maxTokens: { enabled: true, n: 1000 },
blockFieldSuggestions: { enabled: true }, // Don't expose "Did you mean 'apiKeys'?"
});
const complexityRule = createComplexityRule({
maximumComplexity: 1000,
estimators: [simpleEstimator({ defaultComplexity: 1 })],
});
// Apply graphql-shield to the schema (transforms resolvers to add auth middleware)
const schemaWithAuth = applyMiddleware(schema, permissions);
const server = new ApolloServer({
schema: schemaWithAuth,
introspection: false, // Belt
validationRules: [
NoSchemaIntrospectionCustomRule, // Suspenders — belt+suspenders beats either alone
depthLimit(7), // Blocks recursive DoS
complexityRule, // Blocks wide DoS
...armor.protect().validationRules, // Blocks alias+batch abuse
],
plugins: [
...armor.protect().plugins,
],
formatError: (error) => {
// Strip internal error details from responses — SkillAudit checks this
// Never expose stack traces, database errors, or resolver internals
return {
message: error.message,
locations: error.locations,
path: error.path,
extensions: {
code: error.extensions?.code ?? "INTERNAL_SERVER_ERROR",
// Do NOT include: stacktrace, exception, details
},
};
},
});
SkillAudit findings for GraphQL MCP servers
When SkillAudit scans a GraphQL MCP server, the Security and Permissions axes both include GraphQL-specific checks. Here are the findings that affect your grade:
error.extensions
The GraphQL security control matrix
| Attack class | Root cause | Control | Library | SkillAudit axis |
|---|---|---|---|---|
| Schema enumeration | Introspection enabled by default | NoSchemaIntrospectionCustomRule + introspection: false |
graphql, apollo-server | Security |
| Recursive depth DoS | No depth limit on query parsing | depthLimit(7) |
graphql-depth-limit | Security |
| Wide list DoS | No complexity budget per query | createComplexityRule({ maximumComplexity: 1000 }) |
graphql-query-complexity | Security |
| Batch alias abuse | Unlimited aliases per request | maxAliases: { n: 15 } |
graphql-armor | Security |
| Field auth bypass | Auth only at root resolver | Per-field authorization via graphql-shield |
graphql-shield | Permissions |
| Field enumeration | Field suggestions in error messages | blockFieldSuggestions: true |
graphql-armor | Security |
| Error information leakage | Default error formatting includes internals | Custom formatError stripping extensions |
apollo-server | Credential Exposure |
Implementation order for existing GraphQL MCP servers
If you're hardening an existing GraphQL MCP server rather than building a new one, implement the controls in risk-descending order:
Step 1 — Disable introspection (30 minutes, zero breaking changes). Add introspection: false and NoSchemaIntrospectionCustomRule to your Apollo Server config. This has no effect on legitimate clients that already know your schema. Legitimate GraphQL clients don't use introspection at runtime — they use it during development. Your production clients won't notice. This closes the highest-severity finding immediately.
Step 2 — Enable depth limits (30 minutes, test with your deepest legitimate query). Install graphql-depth-limit and add it to your validationRules. Run your test suite against the limit. If any tests fail, increase the limit by 2 until tests pass, then lock it there. Most legitimate MCP server queries are under 7 levels deep.
Step 3 — Enable complexity limits in logging-only mode first (1 week).) Install graphql-query-complexity with maximumComplexity: Infinity and log the complexity of every query for one week. Then set the limit at 2× your p99 query complexity. This avoids accidentally rejecting legitimate complex queries.
Step 4 — Add alias limits (30 minutes). graphql-armor's maxAliases plugin is safe to add at 15. If your application legitimately uses more than 15 aliases in a single query (unusual), increase the limit to accommodate.
Step 5 — Audit field-level authorization (1-3 days). List every sensitive field in your schema — fields containing credentials, PII, billing data, or private configuration. For each, verify that the authorization check lives in the field's resolver, not just in the root resolver that first surfaces the parent type. Use graphql-shield to make the policy declarative and auditable.
Quick self-audit: five questions for your GraphQL MCP server
- Can you send
{ __schema { types { name } } }to your production endpoint and get a response? If yes: introspection is on. - Can you send a query with 20 levels of nesting to your production endpoint without a validation error? If yes: no depth limit.
- Is your per-request rate limit based on HTTP request count? If yes: alias abuse can bypass it.
- Pick your most sensitive type (ApiKey, PaymentMethod, PrivateNote). Can it be reached from more than one root query? If yes: check that the field-level auth guard lives in the type resolver, not just the root resolver.
- Send a query that hits a nonexistent field. Does the error message say "Did you mean X"? If yes: field suggestions are on, leaking your schema one typo at a time.
All five fixes can be shipped in a single PR in a few hours. The GraphQL defaults that enabled your development experience are not the defaults that protect your production MCP server. See the introspection-specific reference page for the complete NoSchemaIntrospectionCustomRule implementation, or run a SkillAudit scan to check all five controls automatically.