Claude skill GraphQL injection
MCP skills that act as clients to GraphQL APIs — querying a backend schema on behalf of the LLM to fetch data, run mutations, or subscribe to real-time events — introduce a category of vulnerabilities that differ from REST API attack patterns. GraphQL’s introspection feature, its alias-based batching, its support for user-supplied query fragments, and its subscription WebSocket transport each present distinct attack surfaces. SA-SKL-GQL-001 through GQL-004 describe four attack patterns that exist in GraphQL-backed MCP skills regardless of the security posture of the underlying GraphQL server: schema exposure via introspection, privilege escalation via alias batching, query tree injection via user-supplied fragments, and real-time data leakage via unauthenticated subscription connections.
GraphQL’s design philosophy — a single endpoint, self-describing schema, client-specified field selection — makes it well-suited for the LLM context, where the model reasons about what data it needs and constructs queries dynamically. But this flexibility also means that input that the LLM synthesises from attacker-controlled external content can influence the query structure itself, not just query parameter values. A skill that passes LLM-constructed query fragments directly to a GraphQL endpoint gives an attacker who can influence the LLM’s context the ability to modify the query tree. Each of the four patterns below describes a distinct failure mode in how a skill interacts with its underlying GraphQL API.
SA-SKL-GQL-001: introspection enabled in production exposes full schema to attackers
GraphQL introspection is a meta-query feature that allows clients to ask the server for its complete schema: every type, every field, every argument, every directive, every mutation, and every subscription. In development this is invaluable for tooling (GraphiQL, Apollo Studio, schema linters). In production it is an attack enabler: an attacker who queries { __schema { types { name fields { name type { name } } } } } receives a complete map of the API surface, including internal fields, admin mutations, and fields that the web UI does not expose. They use this map to find undocumented mutation paths that create admin accounts, fields that return private data from other users, and type mismatches that enable type confusion attacks. Skills that query a GraphQL API often connect to an endpoint where introspection is enabled by default, because the GraphQL server was set up in development mode and never hardened. The skill itself may not use introspection, but if the GraphQL server allows it, any attacker who can send HTTP requests to the server can enumerate the full schema. The fix requires disabling introspection in production on the GraphQL server, not on the skill’s query layer.
// VULNERABLE: GraphQL server has introspection enabled in production
import { ApolloServer } from '@apollo/server';
// Apollo Server enables introspection by default in all environments
const server = new ApolloServer({
typeDefs,
resolvers,
// Missing: introspection: false for production
// Any HTTP client can now: POST /graphql with {"query": "{ __schema { ... } }"}
// Attacker receives complete schema — all types, mutations, internal fields
});
// ALSO VULNERABLE: graphql-http (spec-compliant handler) without introspection guard
import { createHandler } from 'graphql-http/lib/use/express';
app.use('/graphql', createHandler({
schema,
// No validation rule to block IntrospectionQuery — introspection fully open
}));
// SECURE: disable introspection in production; add depth limiting
import { ApolloServer } from '@apollo/server';
import { ApolloServerPluginInlineTrace } from '@apollo/server/plugin/inlineTrace';
import depthLimit from 'graphql-depth-limit';
import { NoIntrospection } from 'graphql-no-introspection';
const server = new ApolloServer({
typeDefs,
resolvers,
// Disable introspection in production
introspection: process.env.NODE_ENV !== 'production',
// Add depth limiting to prevent deeply nested query DoS
validationRules: [
depthLimit(5),
// Block introspection queries via validation rule as belt-and-suspenders
...(process.env.NODE_ENV === 'production' ? [NoIntrospection] : []),
],
});
// For graphql-http:
import { validate } from 'graphql';
import { NoSchemaIntrospectionCustomRule } from 'graphql';
app.use('/graphql', createHandler({
schema,
validate: (schema, documentAST, ...args) => {
const rules = process.env.NODE_ENV === 'production'
? [...specifiedRules, NoSchemaIntrospectionCustomRule]
: specifiedRules;
return validate(schema, documentAST, rules, ...args);
},
}));
// DETECTION (SkillAudit):
// ApolloServer({ }) without introspection:false in production
// OR graphql-http handler without NoSchemaIntrospectionCustomRule in production
const introspectionOpen = searchAST(
'ApolloServer({ }) WITHOUT introspection:false AND env=production'
);
if (introspectionOpen) → HIGH // SA-SKL-GQL-001
Introspection in production is a reconnaissance gift: a skilled attacker uses the introspection response to build a precise attack map of your API in minutes, targeting mutations with missing authorisation, internal types with private fields, and union types that might accept unintended variants. Disabling introspection does not hide known vulnerabilities, but it removes the enumeration step that makes finding them trivial. Enable it only in development environments, never in production or staging environments accessible from outside the organisation.
__schema or __type introspection queries; attacker enumerates full schema including internal mutation paths, undocumented fields, and type hierarchy; discovery step for targeted privilege escalation attacks. Detection: ApolloServer({}) without introspection: false in production environment → HIGH.SA-SKL-GQL-002: alias batching enables N+1 privilege escalation in per-request auth checks
GraphQL aliases allow a client to query the same field multiple times in a single request under different names: { u1: user(id: 1) { email } u2: user(id: 2) { email } ... u100: user(id: 100) { email } }. This is the mechanism behind GraphQL query batching for efficiency. But it also means that a single authenticated request with 100 aliases for 100 different user IDs can bypass authorisation logic that was designed to check the requesting user’s ownership of the requested resource per-request, not per-alias. If the GraphQL resolver for user(id: $id) checks that context.userId === id before returning the record, a single resolver call is correctly protected. But if an attacker sends a single query with 100 aliases, some GraphQL server configurations call the resolver once per alias — and an ownership check that limits to the authenticated user’s own ID prevents each alias individually but does not prevent the aggregate request from enumerating all 100. Skills that expose a GraphQL endpoint or pass alias-batched queries to an upstream API must implement query complexity analysis that counts aliases and rejects requests where alias count exceeds a per-request threshold.
// VULNERABLE: no alias count limit — single request enumerates 100 users
import { ApolloServer } from '@apollo/server';
const resolvers = {
Query: {
user: async (_, { id }, context) => {
// Each resolver call checks auth correctly — but alias batching calls it 100 times
if (context.userId !== id && !context.isAdmin) {
throw new Error('Not authorised');
}
return db.users.findById(id);
}
}
};
// Attack query (no alias limit in place):
// { u1: user(id: 1) { email role } u2: user(id: 2) { email role } ... u100: user(id: 100) { email role } }
// The auth check throws for each individual alias (context.userId !== 1, !== 2 ...)
// But if the schema allows admin lookup by role, one privileged alias can succeed
// OR: the check is wrong and passes for all aliases (common mistake)
// VULNERABLE: ownership check that passes for all aliases when id is any truthy value
const resolvers2 = {
Query: {
user: async (_, { id }, context) => {
if (!context.userId) throw new Error('Not authenticated');
// BUG: only checks that user is authenticated — not that they own the resource
return db.users.findById(id);
// 100 aliases fetch 100 users with a single authenticated session
}
}
};
// SECURE: alias count limit + per-resolver ownership enforcement
import { createComplexityLimitRule } from 'graphql-validation-complexity';
import { fieldExtensionsEstimator, simpleEstimator } from 'graphql-query-complexity';
const server = new ApolloServer({
typeDefs,
resolvers: {
Query: {
user: async (_, { id }, context) => {
// Correct ownership check: user can only fetch their own record (or admin all)
if (context.userId !== id && context.role !== 'admin') {
throw new AuthorisationError('Access denied');
}
return db.users.findById(id);
}
}
},
validationRules: [
// Limit total query complexity — alias batching multiplies field costs
createComplexityLimitRule(100, {
onCost: (cost) => {
if (cost > 100) throw new Error(`Query too complex (cost: ${cost})`);
},
formatErrorMessage: (cost) => `Query complexity ${cost} exceeds limit 100`,
}),
depthLimit(5),
],
plugins: [{
requestDidStart: () => ({
// Additional runtime check: count top-level aliases
didResolveOperation({ request, document }) {
const aliasCount = countTopLevelAliases(document);
if (aliasCount > 10) {
throw new UserInputError('Too many aliases in single request (max 10)');
}
}
})
}],
});
// DETECTION (SkillAudit):
// GraphQL server without query complexity limiting or alias count validation
const noAliasLimit = searchAST(
'ApolloServer({ }) WITHOUT (complexityRule | aliasCountCheck | depthLimit)'
);
if (noAliasLimit) → HIGH // SA-SKL-GQL-002
Alias batching is a GraphQL-native amplification attack: the HTTP request appears to be a single authenticated query from a legitimate user. Authorisation checks that correctly enforce per-resolver ownership are circumvented by the aggregate effect of 100 aliases requesting 100 different resources. Query complexity analysis that assigns a cost to each alias and rejects requests above a threshold is the correct defence, not per-resolver rate limiting or auth hardening alone. The graphql-validation-complexity or graphql-query-complexity library implements this as a validation rule that runs before execution.
ApolloServer({}) without complexityLimitRule or alias-count validation → HIGH.SA-SKL-GQL-003: user-supplied query fragments allow directive injection and query tree manipulation
Some MCP skills allow the LLM to construct GraphQL queries dynamically from tool arguments, or accept user-supplied query fragments that are assembled into a larger query. When a fragment supplied by the user or inferred by the LLM from attacker-controlled external content is included in the query without structural validation, the attacker can inject GraphQL directives, modify field selections, or cause query depth to exceed server-side limits in ways that the skill developer did not anticipate. The most impactful injection uses the @include and @skip directives, which are standard GraphQL directives that conditionally include or exclude fields: an attacker injects adminOnlyField @include(if: true) into a fragment, causing the query to request a field that the skill was never intended to expose. A more dangerous variant on servers that support custom directives injects a custom directive that triggers a side effect, such as @deprecated on a mutation field that aliases to an administrative operation. Skills must never accept arbitrary query strings or fragments from user input and must construct all GraphQL queries from a fixed set of pre-validated query templates.
// VULNERABLE: skill builds query from user-supplied fragment
import { graphql } from 'graphql';
// MCP tool: allows LLM to specify which fields to fetch
async function fetchUserData(args: { userId: string; fields: string }) {
// BUG: fields argument is a raw GraphQL fragment string from LLM/user
// LLM constructs from attacker-controlled document: "name email adminRole @include(if: true)"
const query = `
query {
user(id: "${args.userId}") {
${args.fields}
}
}
`;
// Attack: args.fields = "name email\nadminNotes @include(if: true)\nsecretApiKey"
// Query tree now includes adminNotes and secretApiKey fields
return graphql({ schema, source: query, contextValue: context });
}
// ALSO VULNERABLE: accepting named fragment definitions from user
async function fetchWithFragment(args: { fragmentDef: string }) {
// BUG: full fragment definition from user input assembled into query
const query = `
${args.fragmentDef}
query { user(id: "123") { ...UserFields } }
`;
// Attack: fragment UserFields on User { id email adminUsers { email passwordHash } }
// Access fields the skill was never intended to expose
return graphql({ schema, source: query, contextValue: context });
}
// SECURE: use pre-approved query templates; never accept query strings from input
const USER_QUERIES = {
basic: `
query GetUser($userId: ID!) {
user(id: $userId) { id name email createdAt }
}
`,
withRepos: `
query GetUserWithRepos($userId: ID!) {
user(id: $userId) { id name email repos(first: 10) { name } }
}
`,
};
async function fetchUserData(args: { userId: string; queryType: 'basic' | 'withRepos' }) {
// queryType is validated as an enum — only pre-approved queries run
const query = USER_QUERIES[args.queryType];
if (!query) throw new Error('Unknown query type');
// userId passed as a GraphQL variable — never interpolated into query string
return graphql({
schema,
source: query,
variableValues: { userId: args.userId }, // variables, not interpolation
contextValue: context,
});
}
// If dynamic field selection is genuinely required:
// 1. Define an allowed field allowlist per type
// 2. Parse the requested field list against the allowlist
// 3. Construct the query string from the allowlist only
const ALLOWED_USER_FIELDS = new Set(['id', 'name', 'email', 'createdAt', 'avatarUrl']);
function buildUserQuery(requestedFields: string[]): string {
const safeFields = requestedFields
.filter(f => ALLOWED_USER_FIELDS.has(f))
.join('\n ');
if (!safeFields) throw new Error('No valid fields requested');
return `query GetUser($userId: ID!) { user(id: $userId) { ${safeFields} } }`;
}
// DETECTION (SkillAudit):
// graphql({ source: templateLiteral(args.*) }) — user input in query string
// OR graphql({ source: args.query }) — raw query from args
const fragmentInjection = searchAST(
'graphql({ source: templateLiteral(userInput) | source: args.* })'
);
if (fragmentInjection) → CRITICAL // SA-SKL-GQL-003
GraphQL queries are code, not data: accepting user-controlled strings as GraphQL query fragments and assembling them into query documents gives the attacker the ability to modify the query’s structure, not just its parameters. Pre-validated query templates with GraphQL variables for user-supplied values eliminate this attack surface entirely. If your use case genuinely requires dynamic field selection, implement it as an allowlist-validated field list and construct the query from that list, never from raw user input.
@include(if:true) directives and unlisted field names; query accesses fields the skill was not designed to expose including admin-only data. Detection: graphql({ source: templateLiteral(args.*) }) or source: args.query without pre-approved template enforcement → CRITICAL.SA-SKL-GQL-004: GraphQL subscription WebSocket without authentication enables real-time data leak
GraphQL subscriptions use a WebSocket connection (typically the graphql-ws or legacy subscriptions-transport-ws protocol) to push real-time updates to clients. Authentication over WebSockets requires different handling from HTTP requests: HTTP authentication headers are not automatically forwarded in WebSocket upgrade requests in some client implementations, and the authentication step may need to happen via a connectionParams payload sent at connection initialisation rather than via HTTP headers. MCP skills that implement GraphQL subscriptions often configure authentication correctly for HTTP queries but fail to implement it for the WebSocket connection path. The result is a subscription endpoint that accepts connections from any WebSocket client, unauthenticated, and delivers real-time updates. An attacker who discovers the subscription endpoint can connect and receive a stream of events across all users: audit results as they complete, incoming webhook events, real-time database updates. In a multi-tenant skill, this is a full cross-tenant data disclosure attack.
// VULNERABLE: GraphQL subscription WebSocket accepts connections without auth
import { createServer } from 'http';
import { WebSocketServer } from 'ws';
import { useServer } from 'graphql-ws/lib/use/ws';
import { ApolloServer } from '@apollo/server';
const httpServer = createServer(app);
const wsServer = new WebSocketServer({ server: httpServer, path: '/graphql' });
// BUG: no authentication in onConnect — any WebSocket client receives events
useServer({
schema,
// Missing: onConnect handler that validates connectionParams.authToken
// Any unauthenticated WebSocket client can subscribe to all events
}, wsServer);
// ALSO VULNERABLE: auth checked in context function but onConnect not guarded
useServer({
schema,
context: async (ctx) => {
// This runs per-subscription operation — but connection is already established
const token = ctx.connectionParams?.authToken;
// If token is absent, this might return empty context rather than rejecting
const user = token ? await verifyToken(token) : null;
return { user };
// Resolver checks context.user but a null user might still access public fields
},
}, wsServer);
// SECURE: authenticate at connection time in onConnect; reject unauthenticated connections
import { verifyToken } from './auth';
useServer({
schema,
onConnect: async (ctx) => {
// onConnect runs once at WebSocket connection establishment
// Return false or throw to reject the connection before any subscriptions start
const authToken = ctx.connectionParams?.authToken as string | undefined;
if (!authToken) {
// No token — reject connection entirely
throw new Error('Authentication required for subscriptions');
// graphql-ws closes the WebSocket connection with error code 4401
}
const user = await verifyToken(authToken);
if (!user) {
throw new Error('Invalid authentication token');
}
// Store authenticated user on the connection context for use in resolvers
return { authenticatedUser: user };
},
context: async (ctx) => {
// ctx.extra.authenticatedUser was set in onConnect — guaranteed non-null here
return { user: (ctx as any).authenticatedUser };
},
// Also add subscription-level authorisation in resolvers:
// subscribe: (root, args, context) => {
// if (!context.user) throw new AuthenticationError('...');
// return subscribeToUserEvents(context.user.id); // scoped to user
// }
}, wsServer);
// DETECTION (SkillAudit):
// useServer({ schema }) WITHOUT onConnect handler
// OR useServer({ schema, onConnect: async (ctx) => { /* no rejection on missing token */ } })
const noSubscriptionAuth = searchAST(
'useServer({ schema, WITHOUT onConnect: async => validateAndThrowOnMissingAuth })'
);
if (noSubscriptionAuth) → CRITICAL // SA-SKL-GQL-004
Subscription auth is different from query auth: HTTP authentication middleware (JWT validation in Express, session checks in context functions) runs per-HTTP-request, not per-WebSocket-frame. A WebSocket connection that bypasses the HTTP middleware chain must authenticate independently. The onConnect handler in graphql-ws is the correct location for this: it runs once at connection establishment and can reject the connection before the client sends any subscription operations. Subscription resolver-level auth (if (!context.user) throw) is a necessary belt-and-suspenders but is not sufficient alone — the connection must be rejected before any data is streamed.
useServer({ schema }) without onConnect authentication guard; any WebSocket client connects and subscribes to real-time event streams; in multi-tenant skills this is a cross-tenant data disclosure attack. Detection: useServer({ schema }) without onConnect handler that rejects missing/invalid tokens → CRITICAL.Remediation summary
| Finding | Severity | Fix |
|---|---|---|
| GQL-001: Introspection in production | HIGH | Set introspection: process.env.NODE_ENV !== 'production' in ApolloServer; add NoSchemaIntrospectionCustomRule validation rule as belt-and-suspenders; also disable in staging environments accessible externally |
| GQL-002: Alias batching N+1 | HIGH | Add query complexity validation via graphql-validation-complexity; count top-level aliases in didResolveOperation plugin hook; set per-request alias limit of 10 or fewer; enforce per-resolver ownership checks regardless |
| GQL-003: Fragment injection | CRITICAL | Use pre-approved query templates with variableValues for user-supplied data; never interpolate user strings into query documents; if dynamic fields required, build from an explicit allowlist of field names |
| GQL-004: Subscription WebSocket without auth | CRITICAL | Implement onConnect handler in graphql-ws useServer(); verify connectionParams.authToken and throw on missing or invalid token; also enforce auth in subscription resolver context |
SkillAudit detects all four SA-SKL-GQL-001 through GQL-004 GraphQL attack patterns through static analysis of Apollo Server configuration, query builder code, and WebSocket subscription setup. For broader API security patterns in MCP skills, see SA-SKL-AUTH authentication bypass and SA-SKL-AUTH authorisation bypass. See the complete SA-SKL attack surface reference for the full vulnerability taxonomy.
Scan your Claude skill for GraphQL injection vulnerabilities
SkillAudit detects production introspection, alias batching attacks, fragment injection, and unauthenticated subscription WebSockets across the full SA-SKL-GQL family in 60 seconds.
Get your free audit