GraphQL Security: Injection, Batching Attacks, and Schema Hardening

GraphQL APIs expose a different attack surface from REST — introspection leaks your schema, resolvers are injection vectors, and batching lets attackers bypass rate limiting. This guide covers the practical fixes for each class of GraphQL vulnerability, with Python and JavaScript examples.

GraphQL has become the API layer of choice for many modern applications, and its flexible query model is also a different attack surface from what most developers and security teams are used to assessing for REST APIs. The vulnerabilities that matter in GraphQL are not the same as those in REST — introspection is an attack enabler, resolvers are injection sinks, batching is a rate-limit bypass, and missing field-level authorization checks can expose data that HTTP endpoint permission checks were supposed to protect.

This guide covers the four core GraphQL security concerns with code examples for each.

1. Introspection in Production

GraphQL’s introspection capability allows any client to query the schema: every type, every field, every argument, and every relationship. This is useful in development. In production, it is a reconnaissance gift to attackers.

With introspection enabled, a single query exposes your entire data model:

query IntrospectSchema {
  __schema {
    types {
      name
      fields {
        name
        type { name }
        args { name type { name } }
      }
    }
  }
}

An attacker who can run this query knows your data structures, can identify sensitive fields (anything named password, token, secret, key, ssn), and understands the mutation surface for privilege escalation.

Disable introspection in production:

In Python with Strawberry:

import strawberry
from strawberry.extensions import DisableValidation

schema = strawberry.Schema(
    query=Query,
    mutation=Mutation,
    extensions=[
        # Disable introspection in non-development environments
    ]
)

# Using graphene-django: override the view
from graphene_django.views import GraphQLView

class SecureGraphQLView(GraphQLView):
    def execute_graphql_request(self, request, data, query, *args, **kwargs):
        if "__schema" in query or "__type" in query:
            return None  # Return 400 or empty response
        return super().execute_graphql_request(request, data, query, *args, **kwargs)

In Node.js with Apollo Server:

const { ApolloServer } = require('@apollo/server');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  introspection: process.env.NODE_ENV !== 'production',
  plugins: [
    {
      requestDidStart({ request }) {
        if (process.env.NODE_ENV === 'production') {
          const query = request.query ?? '';
          if (query.includes('__schema') || query.includes('__type')) {
            throw new Error('Introspection is disabled in production');
          }
        }
      }
    }
  ]
});

Note that disabling introspection does not hide your schema from a determined attacker — field suggestion errors and tooling like Clairvoyance can partially reconstruct schemas. Introspection control is one layer, not a complete fix.

2. Injection Through Resolvers

GraphQL resolvers are the functions that translate a GraphQL query into database or service calls. When resolvers construct queries by concatenating GraphQL argument values into raw query strings, the result is injection — SQL, NoSQL, or command injection depending on the backend.

Vulnerable pattern (Python + SQLAlchemy raw SQL):

# VULNERABLE: string interpolation of GraphQL argument into SQL
@strawberry.field
def user(self, username: str) -> Optional[User]:
    query = f"SELECT * FROM users WHERE username = '{username}'"
    result = db.execute(query)
    return result.fetchone()

An attacker passes ' OR '1'='1 as the username argument to extract all users, or '; DROP TABLE users; -- to execute destructive commands.

Secure pattern — parameterised queries:

# SECURE: parameterised query with bound argument
@strawberry.field
def user(self, username: str) -> Optional[User]:
    result = db.execute(
        text("SELECT * FROM users WHERE username = :username"),
        {"username": username}
    )
    return result.fetchone()

Secure pattern in JavaScript (with pg):

// VULNERABLE
async function getUser(username) {
  const result = await pool.query(
    `SELECT * FROM users WHERE username = '${username}'`
  );
  return result.rows[0];
}

// SECURE — parameterised
async function getUser(username) {
  const result = await pool.query(
    'SELECT * FROM users WHERE username = $1',
    [username]
  );
  return result.rows[0];
}

The same principle applies to NoSQL injection in MongoDB resolvers — use parameterised queries or ODM methods that handle escaping, never interpolate GraphQL arguments directly into query documents.

3. Batching and DoS Attacks

GraphQL supports query batching — sending multiple operations in a single HTTP request as a JSON array. Batching is a legitimate feature for reducing network overhead in clients, but it allows attackers to bypass rate limiting that counts HTTP requests rather than operations. A single HTTP request containing 500 mutations can bypass a rate limiter configured to allow 100 requests per minute.

Without batching limit — an attacker can brute-force an OTP in one request:

[
  {"query": "mutation { login(email: \"[email protected]\", otp: \"000000\") { token } }"},
  {"query": "mutation { login(email: \"[email protected]\", otp: \"000001\") { token } }"},
  ...
  {"query": "mutation { login(email: \"[email protected]\", otp: \"999999\") { token } }"}
]

Implement batch limit in Apollo Server:

const server = new ApolloServer({
  typeDefs,
  resolvers,
  plugins: [
    {
      async requestDidStart({ request }) {
        // Reject batch requests exceeding the limit
        if (Array.isArray(request.body)) {
          if (request.body.length > 5) {
            throw new Error('Batch limit exceeded. Maximum 5 operations per request.');
          }
        }
      }
    }
  ]
});

Query depth limiting — prevent nested query amplification:

const depthLimit = require('graphql-depth-limit');
const { createComplexityLimitRule } = require('graphql-validation-complexity');

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [
    depthLimit(7),  // Reject queries deeper than 7 levels
    createComplexityLimitRule(1000, {
      scalarCost: 1,
      objectCost: 2,
      listFactor: 10  // Lists multiply cost — penalise expensive collection queries
    })
  ]
});
# Python equivalent with graphene and graphql-core
from graphql import validate, parse, build_schema
from graphql.validation.rules import NoDeprecatedCustomRule

def validate_query_depth(query_string: str, max_depth: int = 7) -> bool:
    def get_depth(node, depth=0):
        if hasattr(node, 'selection_set') and node.selection_set:
            return max(get_depth(sel, depth + 1) for sel in node.selection_set.selections)
        return depth
    
    document = parse(query_string)
    for definition in document.definitions:
        if get_depth(definition) > max_depth:
            return False
    return True

4. Field-Level Authorization

GraphQL schema design separates type definitions from access control. A field exists on a type because it is structurally relevant to that type — not because every caller should be able to read it. Missing field-level authorization checks mean that a user who can query any field on a type can query all fields on that type, even if HTTP endpoint checks would have blocked them elsewhere.

Vulnerable resolver — no field-level check:

@strawberry.type
class User:
    id: str
    email: str
    # Missing authorization — any authenticated user can read any other user's SSN
    ssn: str
    salary: float

Secure pattern — field-level permission check:

import strawberry
from strawberry.types import Info
from functools import wraps

def require_permission(permission: str):
    def decorator(func):
        @wraps(func)
        async def wrapper(root, info: Info, **kwargs):
            user = info.context.user
            if not user or permission not in user.permissions:
                raise PermissionError(f"Permission denied: {permission} required")
            return await func(root, info, **kwargs)
        return wrapper
    return decorator

@strawberry.type
class User:
    id: str
    email: str
    
    @strawberry.field
    @require_permission("hr:read_sensitive")
    def ssn(self, info: Info) -> str:
        return self._ssn
    
    @strawberry.field
    @require_permission("hr:read_sensitive")
    def salary(self, info: Info) -> float:
        return self._salary

Node.js equivalent with graphql-shield:

const { shield, rule, and } = require('graphql-shield');

const isAuthenticated = rule({ cache: 'contextual' })(
  async (parent, args, ctx) => ctx.user !== null
);

const hasHRPermission = rule({ cache: 'contextual' })(
  async (parent, args, ctx) => ctx.user?.permissions?.includes('hr:read_sensitive')
);

const permissions = shield({
  User: {
    ssn: and(isAuthenticated, hasHRPermission),
    salary: and(isAuthenticated, hasHRPermission),
    email: isAuthenticated,
    id: isAuthenticated
  }
});

Security Testing Your GraphQL API

A quick checklist before deploying a GraphQL endpoint:

  1. Introspection test — confirm __schema queries return an error in production
  2. Batching test — send a batch of 50 identical queries and verify rejection
  3. Depth test — send a query nested 15 levels deep and confirm rejection
  4. Injection test — pass ' OR '1'='1 as string arguments and confirm no SQL errors in response
  5. Authorization test — authenticate as a low-privilege user and attempt to read high-privilege fields

Tools: graphql-cop automates most of these checks.

References