GraphQL Security: Introspection Abuse, Batching Attacks, and Authorization Failures Developers Miss

GraphQL introduces a distinct attack surface compared to REST — introspection exposes the full schema, batching enables rate limit evasion, and field-level authorization is easy to miss. This guide covers the common vulnerabilities and how to fix them.

GraphQL has become the default API layer for a lot of frontend-heavy applications, and it introduces a set of security concerns that don’t map cleanly onto what developers already know about REST. The attack surface is different enough that teams who’ve thought carefully about REST security sometimes deploy GraphQL APIs with fundamental issues they’d never allow in a REST service.

None of these are exotic vulnerabilities. They’re a predictable set of patterns that appear regularly in API security assessments, and they’re straightforward to address once you know what to look for.

Introspection: Your Schema as an Attack Map

GraphQL’s introspection system lets clients query the schema itself — all available types, fields, queries, and mutations. This is a useful developer feature that enables tools like GraphiQL and code generation. It’s also a complete map of your API attack surface for anyone who can reach the endpoint.

A simple introspection query reveals everything:

{
  __schema {
    types {
      name
      fields {
        name
        type { name kind }
        args { name type { name kind } }
      }
    }
  }
}

In development, introspection is fine. In production, it should be disabled unless you have a specific reason to keep it enabled (a public API with intentionally open documentation).

In Apollo Server:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
});

In Strawberry (Python):

schema = strawberry.Schema(
    query=Query,
    mutation=Mutation,
)
app = GraphQL(schema, graphiql=False)  # Also disables introspection in GraphiQL

Note: disabling introspection doesn’t prevent determined attackers from enumerating your schema through field suggestion errors or by fuzzing field names. It raises the bar but isn’t a complete defence. Pair it with proper authentication and authorisation.

Batching and Alias Attacks: Rate Limit Evasion

GraphQL supports query batching — sending an array of operations in a single HTTP request. It also supports field aliases, which let you query the same field multiple times in one request under different names. Both mechanisms can be abused to send what amounts to hundreds of queries in a single HTTP request, bypassing rate limits that operate at the HTTP layer.

A batching-based brute force of a login mutation:

[
  {"query": "mutation { login(email: \"[email protected]\", password: \"Password1\") { token } }"},
  {"query": "mutation { login(email: \"[email protected]\", password: \"Password2\") { token } }"},
  {"query": "mutation { login(email: \"[email protected]\", password: \"Password3\") { token } }"}
]

An alias-based variant within a single operation:

mutation {
  a1: login(email: "[email protected]", password: "Password1") { token }
  a2: login(email: "[email protected]", password: "Password2") { token }
  a3: login(email: "[email protected]", password: "Password3") { token }
}

One HTTP request. Your HTTP-layer rate limiter sees one request and allows it. The GraphQL server executes all three mutations.

Fix: disable operation batching unless you specifically need it, and implement per-operation rate limiting in your resolvers rather than relying solely on HTTP-layer controls.

// Disable batching in Apollo Server 4
const server = new ApolloServer({
  typeDefs,
  resolvers,
  allowBatchedHttpRequests: false, // default is false in v4, but worth making explicit
});

// For alias abuse — limit maximum alias count per operation
// Use a custom validation rule
import { GraphQLError } from 'graphql';

const MaxAliasesRule = (maxAliases) => (validationContext) => {
  return {
    Field(node) {
      if (node.alias) {
        const current = validationContext.getDocument().definitions
          .flatMap(def => def.selectionSet?.selections || [])
          .filter(field => field.alias).length;
        if (current > maxAliases) {
          validationContext.reportError(
            new GraphQLError(`Too many aliases: maximum ${maxAliases} allowed`)
          );
        }
      }
    }
  };
};

Query Depth and Complexity: Denial of Service

Deeply nested GraphQL queries can trigger exponential resolver execution. Consider a schema where User has friends: [User], which in turn has friends: [User]. A query like:

{
  user(id: "1") {
    friends {
      friends {
        friends {
          friends {
            friends { id name }
          }
        }
      }
    }
  }
}

Each level of nesting multiplies the number of database queries. At depth 5 with 100 friends per user, this queries for 100^5 = 10 billion potential records. Without depth limiting, a single request can exhaust your database connection pool.

Fix: implement query depth and complexity limits:

import depthLimit from 'graphql-depth-limit';
import { createComplexityLimitRule } from 'graphql-validation-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(7),  // Maximum 7 levels of nesting
    createComplexityLimitRule(1000),  // Maximum complexity score per query
  ],
});

For Python with Strawberry, use the extensions parameter with a query depth limiter:

from strawberry.extensions import MaxTokensLimiter, QueryDepthLimiter

schema = strawberry.Schema(
    query=Query,
    extensions=[
        QueryDepthLimiter(max_depth=7),
        MaxTokensLimiter(max_token_count=1000),
    ]
)

Authorization: The Field-Level Trap

REST APIs typically enforce authorization at the endpoint level — the route handler checks permissions before doing anything. GraphQL resolvers execute per field, and developers sometimes authorize at the query level (checking if the user can call the query) without checking whether they can access every field returned by that query.

A concrete example: a user query that requires authentication, but the adminNotes field within the User type doesn’t have its own authorization check. Any authenticated user can call { user(id: "1") { adminNotes } } and retrieve fields intended for admins only.

Fix: use a schema directive or middleware to enforce authorization at the field level:

// Using GraphQL Shield for declarative field-level authorization
import { shield, rule, and } from 'graphql-shield';

const isAuthenticated = rule()(async (parent, args, ctx) => {
  return ctx.user !== null;
});

const isAdmin = rule()(async (parent, args, ctx) => {
  return ctx.user?.role === 'admin';
});

export const permissions = shield({
  Query: {
    user: isAuthenticated,
    adminDashboard: isAdmin,
  },
  User: {
    adminNotes: isAdmin,        // Field-level — authenticated users can't see this
    internalFlags: isAdmin,     // Field-level protection
    email: isAuthenticated,     // Any authenticated user can see email
    name: allow,                // Public field
  },
});

Without explicit field-level rules, authorization mistakes are easy to introduce as the schema grows. The directive or middleware approach means authorization is declared alongside the schema rather than buried in resolver logic.

Error Messages and Information Disclosure

GraphQL’s default error handling often returns stack traces and internal resolver errors to clients in development mode. Make sure error formatting is sanitised in production:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  formatError: (formattedError, error) => {
    // Log full error internally
    console.error(error);
    // Return sanitised message to client
    if (process.env.NODE_ENV === 'production') {
      return { message: 'Internal server error' };
    }
    return formattedError;
  },
});

Also disable field suggestion in production — when introspection is disabled, GraphQL still suggests similar field names in errors (“Did you mean userEmail?”), which partially defeats the purpose of disabling introspection.

Testing Your GraphQL API

Run these checks on any GraphQL endpoint before going to production:

# Test introspection is disabled
curl -s -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '{"query": "{__schema{types{name}}}"}' | jq '.errors'

# Test query depth limiting (should be rejected)
# Construct a deep query and verify it returns an error, not data

# Test batching is restricted
curl -s -X POST https://api.example.com/graphql \
  -H "Content-Type: application/json" \
  -d '[{"query":"{viewer{id}}"}, {"query":"{viewer{id}}"}]' | jq 'type'
# Should return "object" (error), not "array" (batch response)

Tools like Clairvoyance can enumerate a GraphQL schema without introspection through field suggestion. Include this in your API security testing to verify that disabling introspection actually limits what an unauthenticated caller can learn.